everywhere: add email notifications

This commit is contained in:
Adhityaa Chandrasekar 2019-02-18 11:23:44 -05:00
parent 69aba94590
commit 06f0f6f014
33 changed files with 872 additions and 30 deletions

1
api/Gopkg.lock generated
View File

@ -161,6 +161,7 @@
"github.com/op/go-logging",
"github.com/russross/blackfriday",
"golang.org/x/crypto/bcrypt",
"golang.org/x/net/html",
"golang.org/x/oauth2",
"golang.org/x/oauth2/github",
"golang.org/x/oauth2/google",

View File

@ -144,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return
}
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": markdownToHtml(*x.Markdown)})
// TODO: reuse html in commentNew and do only one markdown to HTML conversion?
html := markdownToHtml(*x.Markdown)
bodyMarshal(w, response{"success": true, "commentHex": commentHex, "state": state, "html": html})
if smtpConfigured {
go emailNotificationNew(d, path, commenterHex, commentHex, *x.ParentHex, state)
}
}

View File

@ -27,6 +27,10 @@ func commenterNew(email string, name string, link string, photo string, provider
return "", errorEmailAlreadyExists
}
if err := emailNew(email); err != nil {
return "", errorInternal
}
commenterHex, err := randomHex(32)
if err != nil {
return "", errorInternal

View File

@ -0,0 +1,66 @@
package main
import (
"time"
)
func emailNotificationBegin() error {
go func() {
for {
statement := `
SELECT email, sendModeratorNotifications, sendReplyNotifications
FROM emails
WHERE pendingEmails > 0 AND lastEmailNotificationDate < $1;
`
rows, err := db.Query(statement, time.Now().UTC().Add(time.Duration(-10)*time.Minute))
if err != nil {
logger.Errorf("cannot query domains: %v", err)
return
}
defer rows.Close()
for rows.Next() {
var email string
var sendModeratorNotifications bool
var sendReplyNotifications bool
if err = rows.Scan(&email, &sendModeratorNotifications, &sendReplyNotifications); err != nil {
logger.Errorf("cannot scan email in cron job to send notifications: %v", err)
continue
}
if _, ok := emailQueue[email]; !ok {
if err = emailNotificationPendingReset(email); err != nil {
logger.Errorf("error resetting pendingEmails: %v", err)
continue
}
}
cont := true
kindListMap := map[string][]emailNotification{}
for cont {
select {
case e := <-emailQueue[email]:
if _, ok := kindListMap[e.Kind]; !ok {
kindListMap[e.Kind] = []emailNotification{}
}
if (e.Kind == "reply" && sendReplyNotifications) || sendModeratorNotifications {
kindListMap[e.Kind] = append(kindListMap[e.Kind], e)
}
default:
cont = false
break
}
}
for kind, list := range kindListMap {
go emailNotificationSend(email, kind, list)
}
}
time.Sleep(10 * time.Minute)
}
}()
return nil
}

View File

@ -6,6 +6,10 @@ import (
"strings"
)
var goMigrations = map[string](func() error){
"20190213033530-email-notifications.sql": migrateEmails,
}
func migrate() error {
return migrateFromDir(os.Getenv("STATIC") + "/db")
}
@ -69,6 +73,13 @@ func migrateFromDir(dir string) error {
return err
}
if fn, ok := goMigrations[file.Name()]; ok {
if err = fn(); err != nil {
logger.Errorf("cannot execute Go migration associated with SQL %s: %v", f, err)
return err
}
}
completed++
}
}

View File

@ -0,0 +1,37 @@
package main
import ()
func migrateEmails() error {
statement := `
SELECT commenters.email
FROM commenters
UNION
SELECT owners.email
FROM owners
UNION
SELECT moderators.email
FROM moderators;
`
rows, err := db.Query(statement)
if err != nil {
logger.Errorf("cannot get comments: %v", err)
return errorDatabaseMigration
}
defer rows.Close()
for rows.Next() {
var email string
if err = rows.Scan(&email); err != nil {
logger.Errorf("cannot get email from tables during migration: %v", err)
return errorDatabaseMigration
}
if err = emailNew(email); err != nil {
logger.Errorf("cannot insert email during migration: %v", err)
return errorDatabaseMigration
}
}
return nil
}

View File

@ -16,4 +16,5 @@ type domain struct {
RequireIdentification bool `json:"requireIdentification"`
ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
Moderators []moderator `json:"moderators"`
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
}

View File

@ -8,7 +8,7 @@ func domainGet(dmn string) (domain, error) {
}
statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
FROM domains
WHERE domain = $1;
`
@ -16,7 +16,7 @@ func domainGet(dmn string) (domain, error) {
var err error
d := domain{}
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
if err = row.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
return d, errorNoSuchDomain
}

View File

@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
}
statement := `
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous
SELECT domain, ownerHex, name, creationDate, state, importedComments, autoSpamFilter, requireModeration, requireIdentification, moderateAllAnonymous, emailNotificationPolicy
FROM domains
WHERE ownerHex=$1;
`
@ -24,7 +24,7 @@ func domainList(ownerHex string) ([]domain, error) {
domains := []domain{}
for rows.Next() {
d := domain{}
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous); err != nil {
if err = rows.Scan(&d.Domain, &d.OwnerHex, &d.Name, &d.CreationDate, &d.State, &d.ImportedComments, &d.AutoSpamFilter, &d.RequireModeration, &d.RequireIdentification, &d.ModerateAllAnonymous, &d.EmailNotificationPolicy); err != nil {
logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal
}

View File

@ -10,6 +10,11 @@ func domainModeratorNew(domain string, email string) error {
return errorMissingField
}
if err := emailNew(email); err != nil {
logger.Errorf("cannot create email when creating moderator: %v", err)
return errorInternal
}
statement := `
INSERT INTO
moderators (domain, email, addDate)

View File

@ -7,11 +7,11 @@ import (
func domainUpdate(d domain) error {
statement := `
UPDATE domains
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7
SET name=$2, state=$3, autoSpamFilter=$4, requireModeration=$5, requireIdentification=$6, moderateAllAnonymous=$7, emailNotificationPolicy=$8
WHERE domain=$1;
`
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous)
_, err := db.Exec(statement, d.Domain, d.Name, d.State, d.AutoSpamFilter, d.RequireModeration, d.RequireIdentification, d.ModerateAllAnonymous, d.EmailNotificationPolicy)
if err != nil {
logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal

14
api/email.go Normal file
View File

@ -0,0 +1,14 @@
package main
import (
"time"
)
type email struct {
Email string
UnsubscribeSecretHex string
LastEmailNotificationDate time.Time
PendingEmails int
SendReplyNotifications bool
SendModeratorNotifications bool
}

20
api/email_get.go Normal file
View File

@ -0,0 +1,20 @@
package main
import ()
func emailGet(em string) (email, error) {
statement := `
SELECT email, unsubscribeSecretHex, lastEmailNotificationDate, pendingEmails, sendReplyNotifications, sendModeratorNotifications
FROM emails
WHERE email = $1;
`
row := db.QueryRow(statement, em)
e := email{}
if err := row.Scan(&e.Email, &e.UnsubscribeSecretHex, &e.LastEmailNotificationDate, &e.PendingEmails, &e.SendReplyNotifications, &e.SendModeratorNotifications); err != nil {
// TODO: is this the only error?
return e, errorNoSuchEmail
}
return e, nil
}

26
api/email_new.go Normal file
View File

@ -0,0 +1,26 @@
package main
import (
"time"
)
func emailNew(email string) error {
unsubscribeSecretHex, err := randomHex(32)
if err != nil {
return errorInternal
}
statement := `
INSERT INTO
emails (email, unsubscribeSecretHex, lastEmailNotificationDate)
VALUES ($1, $2, $3 )
ON CONFLICT DO NOTHING;
`
_, err = db.Exec(statement, email, unsubscribeSecretHex, time.Now().UTC())
if err != nil {
logger.Errorf("cannot insert email into emails: %v", err)
return errorInternal
}
return nil
}

81
api/email_notification.go Normal file
View File

@ -0,0 +1,81 @@
package main
import (
"time"
)
type emailNotification struct {
Email string
CommenterName string
Domain string
Path string
Title string
CommentHex string
Kind string
}
var emailQueue map[string](chan emailNotification) = map[string](chan emailNotification){}
func emailNotificationPendingResetAll() error {
statement := `
UPDATE emails
SET pendingEmails = 0;
`
_, err := db.Exec(statement)
if err != nil {
logger.Errorf("cannot reset pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingIncrement(email string) error {
statement := `
UPDATE emails
SET pendingEmails = pendingEmails + 1
WHERE email = $1;
`
_, err := db.Exec(statement, email)
if err != nil {
logger.Errorf("cannot increment pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationPendingReset(email string) error {
statement := `
UPDATE emails
SET pendingEmails = 0, lastEmailNotificationDate = $2
WHERE email = $1;
`
_, err := db.Exec(statement, email, time.Now().UTC())
if err != nil {
logger.Errorf("cannot decrement pendingEmails: %v", err)
return err
}
return nil
}
func emailNotificationEnqueue(e emailNotification) error {
if err := emailNotificationPendingIncrement(e.Email); err != nil {
logger.Errorf("cannot increment pendingEmails when enqueueing: %v", err)
return err
}
if _, ok := emailQueue[e.Email]; !ok {
// don't enqueue more than 10 emails as we won't send more than 10 comments
// in one email anyway
emailQueue[e.Email] = make(chan emailNotification, 10)
}
select {
case emailQueue[e.Email] <- e:
default:
}
return nil
}

View File

@ -0,0 +1,138 @@
package main
import ()
func emailNotificationModerator(d domain, path string, title string, commenterHex string, commentHex string, state string) {
if d.EmailNotificationPolicy == "none" {
return
}
// We'll need to check again when we're sending in case the comment was
// approved midway anyway.
if d.EmailNotificationPolicy == "pending-moderation" && state == "approved" {
return
}
var commenterName string
var commenterEmail string
if commenterHex == "anonymous" {
commenterName = "Anonymous"
} else {
c, err := commenterGetByHex(commenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
commenterName = c.Name
commenterEmail = c.Email
}
kind := d.EmailNotificationPolicy
if state != "approved" {
kind = "pending-moderation"
}
for _, m := range d.Moderators {
// Do not email the commenting moderator their own comment.
if commenterHex != "anonymous" && m.Email == commenterEmail {
continue
}
emailNotificationPendingIncrement(m.Email)
emailNotificationEnqueue(emailNotification{
Email: m.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: kind,
})
}
}
func emailNotificationReply(d domain, path string, title string, commenterHex string, commentHex string, parentHex string, state string) {
// No reply notifications for root comments.
if parentHex == "root" {
return
}
// No reply notification emails for unapproved comments.
if state != "approved" {
return
}
statement := `
SELECT commenterHex
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, parentHex)
var parentCommenterHex string
err := row.Scan(&parentCommenterHex)
if err != nil {
logger.Errorf("cannot scan commenterHex and parentCommenterHex: %v", err)
return
}
// No reply notification emails for anonymous users.
if parentCommenterHex == "anonymous" {
return
}
// No reply notification email for self replies.
if parentCommenterHex == commenterHex {
return
}
pc, err := commenterGetByHex(parentCommenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
var commenterName string
if commenterHex == "anonymous" {
commenterName = "Anonymous"
} else {
c, err := commenterGetByHex(commenterHex)
if err != nil {
logger.Errorf("cannot get commenter to send email notification: %v", err)
return
}
commenterName = c.Name
}
// We'll check if they want to receive reply notifications later at the time
// of sending.
emailNotificationEnqueue(emailNotification{
Email: pc.Email,
CommenterName: commenterName,
Domain: d.Domain,
Path: path,
Title: title,
CommentHex: commentHex,
Kind: "reply",
})
}
func emailNotificationNew(d domain, path string, commenterHex string, commentHex string, parentHex string, state string) {
p, err := pageGet(d.Domain, path)
if err != nil {
logger.Errorf("cannot get page to send email notification: %v", err)
return
}
if p.Title == "" {
p.Title, err = pageTitleUpdate(d.Domain, path)
if err != nil {
logger.Errorf("cannot update/get page title to send email notification: %v", err)
return
}
}
emailNotificationModerator(d, path, p.Title, commenterHex, commentHex, state)
emailNotificationReply(d, path, p.Title, commenterHex, commentHex, parentHex, state)
}

View File

@ -0,0 +1,63 @@
package main
import (
"html/template"
)
func emailNotificationSend(em string, kind string, notifications []emailNotification) {
if len(notifications) == 0 {
return
}
e, err := emailGet(em)
if err != nil {
logger.Errorf("cannot get email: %v", err)
return
}
messages := []emailNotificationText{}
for _, notification := range notifications {
statement := `
SELECT html
FROM comments
WHERE commentHex = $1;
`
row := db.QueryRow(statement, notification.CommentHex)
var html string
if err = row.Scan(&html); err != nil {
// the comment was deleted?
// TODO: is this the only error?
return
}
messages = append(messages, emailNotificationText{
emailNotification: notification,
Html: template.HTML(html),
})
}
statement := `
SELECT name
FROM commenters
WHERE email = $1;
`
row := db.QueryRow(statement, em)
var name string
if err := row.Scan(&name); err != nil {
// The moderator has probably not created a commenter account. Let's just
// use their email as name.
name = nameFromEmail(em)
}
if err := emailNotificationPendingReset(em); err != nil {
logger.Errorf("cannot reset after email notification: %v", err)
return
}
if err := smtpEmailNotification(em, name, e.UnsubscribeSecretHex, messages, kind); err != nil {
logger.Errorf("cannot send email notification: %v", err)
return
}
}

View File

@ -42,3 +42,4 @@ var errorInvalidConfigFile = errors.New("Invalid config file.")
var errorInvalidConfigValue = errors.New("Invalid config value.")
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.")
var errorThreadLocked = errors.New("This thread is locked. You cannot add new comments.")
var errorDatabaseMigration = errors.New("Encountered error applying database migration.")

View File

@ -9,6 +9,8 @@ func main() {
exitIfError(smtpTemplatesLoad())
exitIfError(oauthConfigure())
exitIfError(markdownRendererCreate())
exitIfError(emailNotificationPendingResetAll())
exitIfError(emailNotificationBegin())
exitIfError(sigintCleanupSetup())
exitIfError(versionCheckStart())
exitIfError(domainExportCleanupBegin())

View File

@ -46,3 +46,24 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
return o, nil
}
func ownerGetByOwnerHex(ownerHex string) (owner, error) {
if ownerHex == "" {
return owner{}, errorMissingField
}
statement := `
SELECT ownerHex, email, name, confirmedEmail, joinDate
FROM owners
WHERE ownerHex = $1;
`
row := db.QueryRow(statement, ownerHex)
var o owner
if err := row.Scan(&o.OwnerHex, &o.Email, &o.Name, &o.ConfirmedEmail, &o.JoinDate); err != nil {
logger.Errorf("cannot scan owner: %v\n", err)
return owner{}, errorInternal
}
return o, nil
}

View File

@ -20,6 +20,10 @@ func ownerNew(email string, name string, password string) (string, error) {
return "", errorEmailAlreadyExists
}
if err := emailNew(email); err != nil {
return "", errorInternal
}
ownerHex, err := randomHex(32)
if err != nil {
logger.Errorf("cannot generate ownerHex: %v", err)

View File

@ -8,4 +8,5 @@ type page struct {
IsLocked bool `json:"isLocked"`
CommentCount int `json:"commentCount"`
StickyCommentHex string `json:"stickyCommentHex"`
Title string `json:"title"`
}

View File

@ -11,14 +11,14 @@ func pageGet(domain string, path string) (page, error) {
}
statement := `
SELECT isLocked, commentCount, stickyCommentHex
SELECT isLocked, commentCount, stickyCommentHex, title
FROM pages
WHERE domain=$1 AND path=$2;
`
row := db.QueryRow(statement, domain, path)
p := page{Domain: domain, Path: path}
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex); err != nil {
if err := row.Scan(&p.IsLocked, &p.CommentCount, &p.StickyCommentHex, &p.Title); err != nil {
if err == sql.ErrNoRows {
// If there haven't been any comments, there won't be a record for this
// page. The sane thing to do is return defaults.
@ -26,6 +26,7 @@ func pageGet(domain string, path string) (page, error) {
p.IsLocked = false
p.CommentCount = 0
p.StickyCommentHex = "none"
p.Title = ""
} else {
logger.Errorf("error scanning page: %v", err)
return page{}, errorInternal

28
api/page_title.go Normal file
View File

@ -0,0 +1,28 @@
package main
import ()
func pageTitleUpdate(domain string, path string) (string, error) {
title, err := htmlTitleGet("http://" + domain + path)
if err != nil {
// This could fail due to a variety of reasons that we can't control such
// as the user's URL 404 or something, so let's not pollute the error log
// with messages. Just use a sane title. Maybe we'll have the ability to
// retry later.
logger.Errorf("%v", err)
title = domain
}
statement := `
UPDATE pages
SET title = $3
WHERE domain = $1 AND path = $2;
`
_, err = db.Exec(statement, domain, path, title)
if err != nil {
logger.Errorf("cannot update pages table with title: %v", err)
return "", err
}
return title, nil
}

View File

@ -27,6 +27,7 @@ func apiRouterInit(router *mux.Router) error {
router.HandleFunc("/api/commenter/new", commenterNewHandler).Methods("POST")
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
router.HandleFunc("/api/commenter/self", commenterSelfHandler).Methods("POST")
router.HandleFunc("/api/commenter/unsubscribe", commenterSelfHandler).Methods("GET")
router.HandleFunc("/api/oauth/google/redirect", googleRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).Methods("GET")

View File

@ -0,0 +1,86 @@
package main
import (
"bytes"
"fmt"
ht "html/template"
"net/smtp"
"os"
tt "text/template"
)
type emailNotificationText struct {
emailNotification
Html ht.HTML
}
type emailNotificationPlugs struct {
Origin string
Kind string
Subject string
UnsubscribeSecretHex string
Notifications []emailNotificationText
}
func smtpEmailNotification(to string, toName string, unsubscribeSecretHex string, notifications []emailNotificationText, kind string) error {
var subject string
if kind == "reply" {
var verb string
if len(notifications) > 1 {
verb = "replies"
} else {
verb = "reply"
}
subject = fmt.Sprintf("%d new comment %s", len(notifications), verb)
} else {
var verb string
if len(notifications) > 1 {
verb = "comments"
} else {
verb = "comment"
}
if kind == "pending-moderation" {
subject = fmt.Sprintf("%d new %s pending moderation", len(notifications), verb)
} else {
subject = fmt.Sprintf("%d new %s on your website", len(notifications), verb)
}
}
h, err := tt.New("header").Parse(`MIME-Version: 1.0
From: Commento <{{.FromAddress}}>
To: {{.ToName}} <{{.ToAddress}}>
Content-Type: text/html; charset=UTF-8
Subject: {{.Subject}}
`)
var header bytes.Buffer
h.Execute(&header, &headerPlugs{FromAddress: os.Getenv("SMTP_FROM_ADDRESS"), ToAddress: to, ToName: toName, Subject: "[Commento] " + subject})
t, err := ht.ParseFiles(fmt.Sprintf("%s/templates/email-notification.txt", os.Getenv("STATIC")))
if err != nil {
logger.Errorf("cannot parse %s/templates/email-notification.txt: %v", os.Getenv("STATIC"), err)
return errorMalformedTemplate
}
var body bytes.Buffer
err = t.Execute(&body, &emailNotificationPlugs{
Origin: os.Getenv("ORIGIN"),
Kind: kind,
Subject: subject,
UnsubscribeSecretHex: unsubscribeSecretHex,
Notifications: notifications,
})
if err != nil {
logger.Errorf("error generating templated HTML for email notification: %v", err)
return err
}
err = smtp.SendMail(os.Getenv("SMTP_HOST")+":"+os.Getenv("SMTP_PORT"), smtpAuth, os.Getenv("SMTP_FROM_ADDRESS"), []string{to}, concat(header, body))
if err != nil {
logger.Errorf("cannot send email notification: %v", err)
return errorCannotSendEmail
}
return nil
}

View File

@ -31,7 +31,12 @@ Subject: {{.Subject}}
return errorMalformedTemplate
}
names := []string{"confirm-hex", "reset-hex", "domain-export", "domain-export-error"}
names := []string{
"confirm-hex",
"reset-hex",
"domain-export",
"domain-export-error",
}
templates = make(map[string]*template.Template)

36
api/utils_html.go Normal file
View File

@ -0,0 +1,36 @@
package main
import (
"golang.org/x/net/html"
"net/http"
)
func htmlTitleRecurse(h *html.Node) string {
if h.Type == html.ElementNode && h.Data == "title" {
return h.FirstChild.Data
}
for c := h.FirstChild; c != nil; c = c.NextSibling {
res := htmlTitleRecurse(c)
if res != "" {
return res
}
}
return ""
}
func htmlTitleGet(url string) (string, error) {
resp, err := http.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
h, err := html.Parse(resp.Body)
if err != nil {
return "", err
}
return htmlTitleRecurse(h), nil
}

View File

@ -10,6 +10,16 @@ func concat(a bytes.Buffer, b bytes.Buffer) []byte {
return append(a.Bytes(), b.Bytes()...)
}
func nameFromEmail(email string) string {
for i, c := range email {
if c == '@' {
return email[:i]
}
}
return email
}
func exitIfError(err error) {
if err != nil {
fmt.Printf("fatal error: %v\n", err)

View File

@ -0,0 +1,38 @@
-- Email notifications
-- There are two kinds of email notifications: those sent to domain moderators
-- and those sent to commenters. Domain owners can choose to subscribe their
-- moderators to all comments, those pending moderation, or no emails. Each
-- moderator can independently opt out of these emails, of course. Commenters,
-- on the other, can choose to opt into reply notifications by email.
-- TODO: daily and weekly digests instead of just batched real-time emails?
-- TODO: more granular options to unsubscribe from emails for particular
-- domains can be provided - add unsubscribedReplyDomains []TEXT and
-- unsubscribedModeratorDomains []TEXT to emails table?
-- Each address has a cooldown period so that emails aren't sent within 10
-- minutes of each other. Why is this a separate table instead of another
-- column on commenters/owners? Because there may be some mods that haven't
-- logged in to create a row in the commenter table.
CREATE TABLE IF NOT EXISTS emails (
email TEXT NOT NULL UNIQUE PRIMARY KEY,
unsubscribeSecretHex TEXT NOT NULL UNIQUE,
lastEmailNotificationDate TIMESTAMP NOT NULL,
pendingEmails INTEGER NOT NULL DEFAULT 0,
sendReplyNotifications BOOLEAN NOT NULL DEFAULT false,
sendModeratorNotifications BOOLEAN NOT NULL DEFAULT true
);
CREATE INDEX IF NOT EXISTS unsubscribeSecretHexIndex ON emails(unsubscribeSecretHex);
-- Which comments should be sent?
-- Possible values: all, pending-moderation, none
-- Default to pending-moderation because this is critical. If the user forgets
-- to moderate, some comments will never see the light of day.
ALTER TABLE domains
ADD COLUMN emailNotificationPolicy TEXT DEFAULT 'pending-moderation';
-- Each page now needs to store the title of the page.
ALTER TABLE pages
ADD COLUMN title TEXT DEFAULT '';

View File

@ -160,6 +160,7 @@
<ul class="tabs">
<li class="tab-link original current" data-tab="mod-tab-1">General</li>
<li class="tab-link" data-tab="mod-tab-2">Add/Remove Moderators</li>
<li class="tab-link" data-tab="mod-tab-3">Email Settings</li>
</ul>
<div id="mod-tab-1" class="content original current">
@ -201,7 +202,7 @@
</div>
<div id="mod-tab-2" class="content">
<div class="pitch">
<div class="normal-text">
Moderators have the power to approve/delete comments and lock threads. Once you add an user as a moderator, shiny new buttons will appear on each comment on each page when they log in.<br><br>
You're still the only administrator and the only person who can add and remove moderators. Moderators do not have access to this dashboard. Their access is restricted to pages on your website.
@ -222,6 +223,31 @@
</div>
</div>
</div>
<div id="mod-tab-3" class="content">
<div class="normal-text">
You can enable email notifications to notify your moderators when a new comment is posted or when a comment is pending moderation. Commento tries to be smart about how often an email is sent. Emails will be delayed and batched until you go 10 minutes without one. This requires valid SMTP settings in order to send emails.<br><br>
</div>
<div class="question">
When do you want emails sent to moderators?
</div>
<div class="row no-border commento-round-check indent">
<input type="radio" id="email-all" value="all" v-model="domains[cd].emailNotificationPolicy">
<label for="email-all">Whenever a new comment is created</label>
</div>
<div class="row no-border commento-round-check indent">
<input type="radio" id="email-pending-moderation" value="pending-moderation" v-model="domains[cd].emailNotificationPolicy">
<label for="email-pending-moderation">Only for comments pending moderation</label>
</div>
<div class="row no-border commento-round-check indent">
<input type="radio" id="email-none" value="none" v-model="domains[cd].emailNotificationPolicy">
<label for="email-none">Do not email moderators</label>
</div>
<br>
<div class="center">
<button id="save-general-button" onclick="window.commento.generalSaveHandler()" class="button">Save Changes</button>
</div>
</div>
</div>
</div>
</div>
@ -236,8 +262,7 @@
<div class="tab">
<ul class="tabs">
<li class="tab-link original current" data-tab="configure-tab-1">General</li>
<!-- <li class="tab-link" data-tab="configure-tab-2">Email Settings</li> -->
<li class="tab-link" data-tab="configure-tab-3">Export Data</li>
<li class="tab-link" data-tab="configure-tab-2">Export Data</li>
</ul>
<div id="configure-tab-1" class="content original current">
@ -252,12 +277,7 @@
</div>
</div>
<!--
<div id="configure-tab-2" class="content">
</div>
-->
<div id="configure-tab-3" class="content">
<div class="normal-text">
You can export an archive of this domain's data (which includes all comments and commenters) in the JSON format. To initiate and queue an archive request, click the button below. You will receive an email containing the archive once it's ready.<br><br>

View File

@ -1,10 +1,12 @@
@import "colors-main.scss";
.commento-round-check {
input[type="radio"],
input[type="checkbox"] {
display: none;
}
input[type="radio"] + label,
input[type="checkbox"] + label {
display: block;
position: relative;
@ -16,8 +18,12 @@
-ms-user-select: none;
}
input[type="checkbox"] + label:last-child { margin-bottom: 0; }
input[type="radio"] + label:last-child,
input[type="checkbox"] + label:last-child {
margin-bottom: 0;
}
input[type="radio"] + label:before,
input[type="checkbox"] + label:before {
content: '';
display: block;
@ -33,17 +39,20 @@
transition: all .15s;
}
input[type="radio"]:disabled + label:before,
input[type="checkbox"]:disabled + label:before {
background: $gray-0;
border: 1px solid $gray-4;
opacity: 0.4;
}
input[type="radio"]:checked + label:before,
input[type="checkbox"]:checked + label:before {
background: $blue-6;
border: 1px solid $blue-6;
}
input[type="radio"] + label:after,
input[type="checkbox"] + label:after {
position: absolute;
left: -7px;
@ -59,11 +68,13 @@
border-width: 0 2px 2px 0;
}
input[type="radio"]:disabled + label:after,
input[type="checkbox"]:disabled + label:after {
border: solid transparent;
border-width: 0 2px 2px 0;
}
input[type="radio"]:checked + label:after,
input[type="checkbox"]:checked + label:after {
border: solid $gray-0;
border-width: 0 2px 2px 0;

View File

@ -0,0 +1,104 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN" "http://www.w3.org/TR/REC-html40/loose.dtd">
<html>
<head>
<meta name="viewport" content="user-scalable=no,initial-scale=1">
<title>You have {{ .Subject }}</title>
<style type="text/css">
@media only screen and (max-width:600px) {
.options {
float: none;
margin-bottom: 16px;
text-align: right;
}
.options::before {
content: "Options:";
float: left;
text-transform: uppercase;
color: #495057;
font-size: 12px;
font-weight: bold;
}
.option {
padding: 6px 8px;
margin: 10px 5px;
color: white;
border-radius: 2px;
}
.green {
background: #2f9e44;
}
.red {
background: #f03e3e;
}
.blue {
background: #1c7ed6;
}
.gray {
background: #495057;
}
.header {
padding-right: 0px;
}
.logo {
display: block;
float: none;
text-align: center;
width: 100%;
margin: 0px 8px 16px 0px;
}
.unsubscribe {
max-width: 100%;
width: 100%;
text-align: left;
margin-left: 8px;
}
}
</style>
</head>
<body class="content" style="font-size:14px;background:white;font-family:sans-serif;padding:0px;margin:0px;">
<div class="h1" style="font-weight:bold;text-align:center;margin-top:12px;padding:8px;font-size:24px;">You have {{ .Subject }}</div>
<div class="comments-container" style="display:flex;justify-content:center;">
<div class="comments" style="max-width:600px;width:calc(100% - 20px);margin-top:16px;border-top:1px solid #eee;">
{{ with .Notifications }}
{{ range . }}
<div class="comment" style="border-radius:2px;width:calc(100% - 32px);padding:16px;margin:8px 0px 8px 0px;border-bottom:1px solid #eee;">
<div class="options" style="float:right;">
{{ if eq .Kind "pending-moderation" }}
<a href="{{ $.Origin }}/api/moderate/email?commentHex={{ .CommentHex }}&action=approve&unsubscribeSecretHex={{ $.UnsubscribeSecretHex }}" target="_black" class="option green" style="padding-right:5px;text-transform:uppercase;font-size:12px;font-weight:bold;text-decoration:none;color:#2f9e44;">Approve</a>
{{ end }}
{{ if ne .Kind "reply" }}
<a href="{{ $.Origin }}/api/moderate/email?commentHex={{ .CommentHex }}&action=delete&unsubscribeSecretHex={{ $.UnsubscribeSecretHex }}" target="_black" class="option red" style="padding-right:5px;text-transform:uppercase;font-size:12px;font-weight:bold;text-decoration:none;color:#f03e3e;">Delete</a>
{{ end }}
<a href="http://{{ .Domain }}{{ .Path }}#commento-{{ .CommentHex }}" class="option gray" style="padding-right:5px;text-transform:uppercase;font-size:12px;font-weight:bold;text-decoration:none;color:#495057;">Context</a>
</div>
<div class="header" style="white-space:nowrap;overflow:hidden;text-overflow:ellipsis;padding-right:10px;">
<div class="name" style="display:inline;font-size:14px;font-weight:bold;color:#1e2127;">{{ .CommenterName }}</div>
on
<a href="http://{{ .Domain }}{{ .Path }}" class="page" style="margin-bottom:10px;text-decoration:none;color:#228be6;">"{{ .Title }}"</a>
</div>
<div class="text" style="line-height:20px;padding:10px;">
{{ .Html }}
</div>
</div>
{{ end }}
{{ end }}
<div class="footer" style="width:100%;margin-top:16px;">
<a href="https://commento.io" class="logo" style="float:right;font-weight:bold;color:#868e96;font-size:13px;text-decoration:none;">Powered by Commento</a>
<div class="unsubscribe" style="color:#868e96;font-size:13px;text-align:left;max-width:300px;margin-bottom:16px;">
{{ if eq .Kind "reply" }}
You've received this email because you opted in to receive email notifications for comment replies. To unsubscribe, <a href="{{ .Origin }}/unsubscribe?unsubscribeSecretHex={{ .UnsubscribeSecretHex }}" style="color:#868e96;font-weight:bold;text-decoration:none;">click here</a>.
{{ end }}
{{ if eq .Kind "pending-moderation" }}
You've received this email because the domain owner chose to notify moderators of comments pending moderation by email. To unsubscribe, <a href="{{ .Origin }}/unsubscribe?unsubscribeSecretHex={{ .UnsubscribeSecretHex }}" style="color:#868e96;font-weight:bold;text-decoration:none;">click here</a>.
{{ end }}
{{ if eq .Kind "all" }}
You've received this email because the domain owner chose to notify moderators for all new comments by email. To unsubscribe, <a href="{{ .Origin }}/unsubscribe?unsubscribeSecretHex={{ .UnsubscribeSecretHex }}" style="color:#868e96;font-weight:bold;text-decoration:none;">click here</a>.
{{ end }}
</div>
</div>
</div>
</div>
</body>
</html>