diff --git a/api/Gopkg.lock b/api/Gopkg.lock index bd83984..4cc5215 100644 --- a/api/Gopkg.lock +++ b/api/Gopkg.lock @@ -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", diff --git a/api/comment_new.go b/api/comment_new.go index 57cf54d..5cb3656 100644 --- a/api/comment_new.go +++ b/api/comment_new.go @@ -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) + } } diff --git a/api/commenter_new.go b/api/commenter_new.go index 9d8590d..84a0d88 100644 --- a/api/commenter_new.go +++ b/api/commenter_new.go @@ -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 diff --git a/api/cron_email_notification.go b/api/cron_email_notification.go new file mode 100644 index 0000000..48d86ad --- /dev/null +++ b/api/cron_email_notification.go @@ -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 +} diff --git a/api/database_migrate.go b/api/database_migrate.go index d777c55..c76f70b 100644 --- a/api/database_migrate.go +++ b/api/database_migrate.go @@ -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++ } } diff --git a/api/database_migrate_email_notifications.go b/api/database_migrate_email_notifications.go new file mode 100644 index 0000000..cd7951f --- /dev/null +++ b/api/database_migrate_email_notifications.go @@ -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 +} diff --git a/api/domain.go b/api/domain.go index 98a72e7..c5f7a51 100644 --- a/api/domain.go +++ b/api/domain.go @@ -5,15 +5,16 @@ import ( ) type domain struct { - Domain string `json:"domain"` - OwnerHex string `json:"ownerHex"` - Name string `json:"name"` - CreationDate time.Time `json:"creationDate"` - State string `json:"state"` - ImportedComments bool `json:"importedComments"` - AutoSpamFilter bool `json:"autoSpamFilter"` - RequireModeration bool `json:"requireModeration"` - RequireIdentification bool `json:"requireIdentification"` - ModerateAllAnonymous bool `json:"moderateAllAnonymous"` - Moderators []moderator `json:"moderators"` + Domain string `json:"domain"` + OwnerHex string `json:"ownerHex"` + Name string `json:"name"` + CreationDate time.Time `json:"creationDate"` + State string `json:"state"` + ImportedComments bool `json:"importedComments"` + AutoSpamFilter bool `json:"autoSpamFilter"` + RequireModeration bool `json:"requireModeration"` + RequireIdentification bool `json:"requireIdentification"` + ModerateAllAnonymous bool `json:"moderateAllAnonymous"` + Moderators []moderator `json:"moderators"` + EmailNotificationPolicy string `json:"emailNotificationPolicy"` } diff --git a/api/domain_get.go b/api/domain_get.go index ea9447a..b0c036f 100644 --- a/api/domain_get.go +++ b/api/domain_get.go @@ -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 } diff --git a/api/domain_list.go b/api/domain_list.go index 5311e0e..f1eca8c 100644 --- a/api/domain_list.go +++ b/api/domain_list.go @@ -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 } diff --git a/api/domain_moderator_new.go b/api/domain_moderator_new.go index 3ec52d4..f0d8d69 100644 --- a/api/domain_moderator_new.go +++ b/api/domain_moderator_new.go @@ -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) diff --git a/api/domain_update.go b/api/domain_update.go index e6c7cbf..c1a87ff 100644 --- a/api/domain_update.go +++ b/api/domain_update.go @@ -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 diff --git a/api/email.go b/api/email.go new file mode 100644 index 0000000..97013c9 --- /dev/null +++ b/api/email.go @@ -0,0 +1,14 @@ +package main + +import ( + "time" +) + +type email struct { + Email string + UnsubscribeSecretHex string + LastEmailNotificationDate time.Time + PendingEmails int + SendReplyNotifications bool + SendModeratorNotifications bool +} diff --git a/api/email_get.go b/api/email_get.go new file mode 100644 index 0000000..5a4d29b --- /dev/null +++ b/api/email_get.go @@ -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 +} diff --git a/api/email_new.go b/api/email_new.go new file mode 100644 index 0000000..4a189bb --- /dev/null +++ b/api/email_new.go @@ -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 +} diff --git a/api/email_notification.go b/api/email_notification.go new file mode 100644 index 0000000..88892f0 --- /dev/null +++ b/api/email_notification.go @@ -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 +} diff --git a/api/email_notification_new.go b/api/email_notification_new.go new file mode 100644 index 0000000..2ace972 --- /dev/null +++ b/api/email_notification_new.go @@ -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) +} diff --git a/api/email_notification_send.go b/api/email_notification_send.go new file mode 100644 index 0000000..8f81891 --- /dev/null +++ b/api/email_notification_send.go @@ -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 + } +} diff --git a/api/errors.go b/api/errors.go index c6058a6..94bf59c 100644 --- a/api/errors.go +++ b/api/errors.go @@ -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.") diff --git a/api/main.go b/api/main.go index 9bffe64..8133976 100644 --- a/api/main.go +++ b/api/main.go @@ -9,6 +9,8 @@ func main() { exitIfError(smtpTemplatesLoad()) exitIfError(oauthConfigure()) exitIfError(markdownRendererCreate()) + exitIfError(emailNotificationPendingResetAll()) + exitIfError(emailNotificationBegin()) exitIfError(sigintCleanupSetup()) exitIfError(versionCheckStart()) exitIfError(domainExportCleanupBegin()) diff --git a/api/owner_get.go b/api/owner_get.go index dcd53f3..5a30965 100644 --- a/api/owner_get.go +++ b/api/owner_get.go @@ -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 +} diff --git a/api/owner_new.go b/api/owner_new.go index 26a9b9a..874e67f 100644 --- a/api/owner_new.go +++ b/api/owner_new.go @@ -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) diff --git a/api/page.go b/api/page.go index 8bdc8b1..119f4cb 100644 --- a/api/page.go +++ b/api/page.go @@ -8,4 +8,5 @@ type page struct { IsLocked bool `json:"isLocked"` CommentCount int `json:"commentCount"` StickyCommentHex string `json:"stickyCommentHex"` + Title string `json:"title"` } diff --git a/api/page_get.go b/api/page_get.go index a7c2da4..28dc5a9 100644 --- a/api/page_get.go +++ b/api/page_get.go @@ -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 diff --git a/api/page_title.go b/api/page_title.go new file mode 100644 index 0000000..ab0ae22 --- /dev/null +++ b/api/page_title.go @@ -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 +} diff --git a/api/router_api.go b/api/router_api.go index 2bbaf18..c6fef40 100644 --- a/api/router_api.go +++ b/api/router_api.go @@ -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") diff --git a/api/smtp_email_notification.go b/api/smtp_email_notification.go new file mode 100644 index 0000000..eb43b77 --- /dev/null +++ b/api/smtp_email_notification.go @@ -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 +} diff --git a/api/smtp_templates.go b/api/smtp_templates.go index c7f8e71..a67472d 100644 --- a/api/smtp_templates.go +++ b/api/smtp_templates.go @@ -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) diff --git a/api/utils_html.go b/api/utils_html.go new file mode 100644 index 0000000..92cbac7 --- /dev/null +++ b/api/utils_html.go @@ -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 +} diff --git a/api/utils_misc.go b/api/utils_misc.go index e40f46d..19415f5 100644 --- a/api/utils_misc.go +++ b/api/utils_misc.go @@ -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) diff --git a/db/20190213033530-email-notifications.sql b/db/20190213033530-email-notifications.sql new file mode 100644 index 0000000..b58c203 --- /dev/null +++ b/db/20190213033530-email-notifications.sql @@ -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 ''; diff --git a/frontend/dashboard.html b/frontend/dashboard.html index 4c4d344..dba5904 100644 --- a/frontend/dashboard.html +++ b/frontend/dashboard.html @@ -160,6 +160,7 @@
@@ -201,7 +202,7 @@
-
+
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.

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 @@
+ +
+
+ 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.

+
+
+ When do you want emails sent to moderators? +
+
+ + +
+
+ + +
+
+ + +
+
+
+ +
+
@@ -236,8 +262,7 @@
@@ -252,12 +277,7 @@
- - -
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.

diff --git a/frontend/sass/checkbox.scss b/frontend/sass/checkbox.scss index 66a9632..fcf0e34 100644 --- a/frontend/sass/checkbox.scss +++ b/frontend/sass/checkbox.scss @@ -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; diff --git a/templates/email-notification.txt b/templates/email-notification.txt new file mode 100644 index 0000000..edbe18b --- /dev/null +++ b/templates/email-notification.txt @@ -0,0 +1,104 @@ + + + + + You have {{ .Subject }} + + + +
You have {{ .Subject }}
+
+
+ {{ with .Notifications }} + {{ range . }} + +
+
+ {{ if eq .Kind "pending-moderation" }} + Approve + {{ end }} + {{ if ne .Kind "reply" }} + Delete + {{ end }} + Context +
+
+
{{ .CommenterName }}
+ on + "{{ .Title }}" +
+
+ {{ .Html }} +
+
+ {{ end }} + {{ end }} + + +
+
+ +