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/op/go-logging",
"github.com/russross/blackfriday", "github.com/russross/blackfriday",
"golang.org/x/crypto/bcrypt", "golang.org/x/crypto/bcrypt",
"golang.org/x/net/html",
"golang.org/x/oauth2", "golang.org/x/oauth2",
"golang.org/x/oauth2/github", "golang.org/x/oauth2/github",
"golang.org/x/oauth2/google", "golang.org/x/oauth2/google",

View File

@ -144,5 +144,11 @@ func commentNewHandler(w http.ResponseWriter, r *http.Request) {
return 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 return "", errorEmailAlreadyExists
} }
if err := emailNew(email); err != nil {
return "", errorInternal
}
commenterHex, err := randomHex(32) commenterHex, err := randomHex(32)
if err != nil { if err != nil {
return "", errorInternal 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" "strings"
) )
var goMigrations = map[string](func() error){
"20190213033530-email-notifications.sql": migrateEmails,
}
func migrate() error { func migrate() error {
return migrateFromDir(os.Getenv("STATIC") + "/db") return migrateFromDir(os.Getenv("STATIC") + "/db")
} }
@ -69,6 +73,13 @@ func migrateFromDir(dir string) error {
return err 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++ 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

@ -5,15 +5,16 @@ import (
) )
type domain struct { type domain struct {
Domain string `json:"domain"` Domain string `json:"domain"`
OwnerHex string `json:"ownerHex"` OwnerHex string `json:"ownerHex"`
Name string `json:"name"` Name string `json:"name"`
CreationDate time.Time `json:"creationDate"` CreationDate time.Time `json:"creationDate"`
State string `json:"state"` State string `json:"state"`
ImportedComments bool `json:"importedComments"` ImportedComments bool `json:"importedComments"`
AutoSpamFilter bool `json:"autoSpamFilter"` AutoSpamFilter bool `json:"autoSpamFilter"`
RequireModeration bool `json:"requireModeration"` RequireModeration bool `json:"requireModeration"`
RequireIdentification bool `json:"requireIdentification"` RequireIdentification bool `json:"requireIdentification"`
ModerateAllAnonymous bool `json:"moderateAllAnonymous"` ModerateAllAnonymous bool `json:"moderateAllAnonymous"`
Moderators []moderator `json:"moderators"` Moderators []moderator `json:"moderators"`
EmailNotificationPolicy string `json:"emailNotificationPolicy"`
} }

View File

@ -8,7 +8,7 @@ func domainGet(dmn string) (domain, error) {
} }
statement := ` 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 FROM domains
WHERE domain = $1; WHERE domain = $1;
` `
@ -16,7 +16,7 @@ func domainGet(dmn string) (domain, error) {
var err error var err error
d := domain{} 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 return d, errorNoSuchDomain
} }

View File

@ -10,7 +10,7 @@ func domainList(ownerHex string) ([]domain, error) {
} }
statement := ` 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 FROM domains
WHERE ownerHex=$1; WHERE ownerHex=$1;
` `
@ -24,7 +24,7 @@ func domainList(ownerHex string) ([]domain, error) {
domains := []domain{} domains := []domain{}
for rows.Next() { for rows.Next() {
d := domain{} 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) logger.Errorf("cannot Scan domain: %v", err)
return nil, errorInternal return nil, errorInternal
} }

View File

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

View File

@ -7,11 +7,11 @@ import (
func domainUpdate(d domain) error { func domainUpdate(d domain) error {
statement := ` statement := `
UPDATE domains 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; 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 { if err != nil {
logger.Errorf("cannot update non-moderators: %v", err) logger.Errorf("cannot update non-moderators: %v", err)
return errorInternal 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 errorInvalidConfigValue = errors.New("Invalid config value.")
var errorNewOwnerForbidden = errors.New("New user registrations are forbidden and closed.") 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 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(smtpTemplatesLoad())
exitIfError(oauthConfigure()) exitIfError(oauthConfigure())
exitIfError(markdownRendererCreate()) exitIfError(markdownRendererCreate())
exitIfError(emailNotificationPendingResetAll())
exitIfError(emailNotificationBegin())
exitIfError(sigintCleanupSetup()) exitIfError(sigintCleanupSetup())
exitIfError(versionCheckStart()) exitIfError(versionCheckStart())
exitIfError(domainExportCleanupBegin()) exitIfError(domainExportCleanupBegin())

View File

@ -46,3 +46,24 @@ func ownerGetByOwnerToken(ownerToken string) (owner, error) {
return o, nil 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 return "", errorEmailAlreadyExists
} }
if err := emailNew(email); err != nil {
return "", errorInternal
}
ownerHex, err := randomHex(32) ownerHex, err := randomHex(32)
if err != nil { if err != nil {
logger.Errorf("cannot generate ownerHex: %v", err) logger.Errorf("cannot generate ownerHex: %v", err)

View File

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

View File

@ -11,14 +11,14 @@ func pageGet(domain string, path string) (page, error) {
} }
statement := ` statement := `
SELECT isLocked, commentCount, stickyCommentHex SELECT isLocked, commentCount, stickyCommentHex, title
FROM pages FROM pages
WHERE domain=$1 AND path=$2; WHERE domain=$1 AND path=$2;
` `
row := db.QueryRow(statement, domain, path) row := db.QueryRow(statement, domain, path)
p := page{Domain: domain, Path: 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 err == sql.ErrNoRows {
// If there haven't been any comments, there won't be a record for this // If there haven't been any comments, there won't be a record for this
// page. The sane thing to do is return defaults. // 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.IsLocked = false
p.CommentCount = 0 p.CommentCount = 0
p.StickyCommentHex = "none" p.StickyCommentHex = "none"
p.Title = ""
} else { } else {
logger.Errorf("error scanning page: %v", err) logger.Errorf("error scanning page: %v", err)
return page{}, errorInternal 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/new", commenterNewHandler).Methods("POST")
router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST") router.HandleFunc("/api/commenter/login", commenterLoginHandler).Methods("POST")
router.HandleFunc("/api/commenter/self", commenterSelfHandler).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/redirect", googleRedirectHandler).Methods("GET")
router.HandleFunc("/api/oauth/google/callback", googleCallbackHandler).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 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) 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()...) 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) { func exitIfError(err error) {
if err != nil { if err != nil {
fmt.Printf("fatal error: %v\n", err) 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"> <ul class="tabs">
<li class="tab-link original current" data-tab="mod-tab-1">General</li> <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-2">Add/Remove Moderators</li>
<li class="tab-link" data-tab="mod-tab-3">Email Settings</li>
</ul> </ul>
<div id="mod-tab-1" class="content original current"> <div id="mod-tab-1" class="content original current">
@ -201,7 +202,7 @@
</div> </div>
<div id="mod-tab-2" class="content"> <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> 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. 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>
</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> </div>
</div> </div>
@ -236,8 +262,7 @@
<div class="tab"> <div class="tab">
<ul class="tabs"> <ul class="tabs">
<li class="tab-link original current" data-tab="configure-tab-1">General</li> <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-2">Export Data</li>
<li class="tab-link" data-tab="configure-tab-3">Export Data</li>
</ul> </ul>
<div id="configure-tab-1" class="content original current"> <div id="configure-tab-1" class="content original current">
@ -252,12 +277,7 @@
</div> </div>
</div> </div>
<!--
<div id="configure-tab-2" class="content"> <div id="configure-tab-2" class="content">
</div>
-->
<div id="configure-tab-3" class="content">
<div class="normal-text"> <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> 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"; @import "colors-main.scss";
.commento-round-check { .commento-round-check {
input[type="radio"],
input[type="checkbox"] { input[type="checkbox"] {
display: none; display: none;
} }
input[type="radio"] + label,
input[type="checkbox"] + label { input[type="checkbox"] + label {
display: block; display: block;
position: relative; position: relative;
@ -16,8 +18,12 @@
-ms-user-select: none; -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 { input[type="checkbox"] + label:before {
content: ''; content: '';
display: block; display: block;
@ -33,17 +39,20 @@
transition: all .15s; transition: all .15s;
} }
input[type="radio"]:disabled + label:before,
input[type="checkbox"]:disabled + label:before { input[type="checkbox"]:disabled + label:before {
background: $gray-0; background: $gray-0;
border: 1px solid $gray-4; border: 1px solid $gray-4;
opacity: 0.4; opacity: 0.4;
} }
input[type="radio"]:checked + label:before,
input[type="checkbox"]:checked + label:before { input[type="checkbox"]:checked + label:before {
background: $blue-6; background: $blue-6;
border: 1px solid $blue-6; border: 1px solid $blue-6;
} }
input[type="radio"] + label:after,
input[type="checkbox"] + label:after { input[type="checkbox"] + label:after {
position: absolute; position: absolute;
left: -7px; left: -7px;
@ -59,11 +68,13 @@
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
} }
input[type="radio"]:disabled + label:after,
input[type="checkbox"]:disabled + label:after { input[type="checkbox"]:disabled + label:after {
border: solid transparent; border: solid transparent;
border-width: 0 2px 2px 0; border-width: 0 2px 2px 0;
} }
input[type="radio"]:checked + label:after,
input[type="checkbox"]:checked + label:after { input[type="checkbox"]:checked + label:after {
border: solid $gray-0; border: solid $gray-0;
border-width: 0 2px 2px 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>