everywhere: add email notifications
This commit is contained in:
parent
69aba94590
commit
06f0f6f014
1
api/Gopkg.lock
generated
1
api/Gopkg.lock
generated
@ -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",
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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
|
||||
|
66
api/cron_email_notification.go
Normal file
66
api/cron_email_notification.go
Normal 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
|
||||
}
|
@ -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++
|
||||
}
|
||||
}
|
||||
|
37
api/database_migrate_email_notifications.go
Normal file
37
api/database_migrate_email_notifications.go
Normal 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
|
||||
}
|
@ -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"`
|
||||
}
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -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
14
api/email.go
Normal 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
20
api/email_get.go
Normal 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
26
api/email_new.go
Normal 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
81
api/email_notification.go
Normal 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
|
||||
}
|
138
api/email_notification_new.go
Normal file
138
api/email_notification_new.go
Normal 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)
|
||||
}
|
63
api/email_notification_send.go
Normal file
63
api/email_notification_send.go
Normal 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
|
||||
}
|
||||
}
|
@ -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.")
|
||||
|
@ -9,6 +9,8 @@ func main() {
|
||||
exitIfError(smtpTemplatesLoad())
|
||||
exitIfError(oauthConfigure())
|
||||
exitIfError(markdownRendererCreate())
|
||||
exitIfError(emailNotificationPendingResetAll())
|
||||
exitIfError(emailNotificationBegin())
|
||||
exitIfError(sigintCleanupSetup())
|
||||
exitIfError(versionCheckStart())
|
||||
exitIfError(domainExportCleanupBegin())
|
||||
|
@ -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
|
||||
}
|
||||
|
@ -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)
|
||||
|
@ -8,4 +8,5 @@ type page struct {
|
||||
IsLocked bool `json:"isLocked"`
|
||||
CommentCount int `json:"commentCount"`
|
||||
StickyCommentHex string `json:"stickyCommentHex"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
@ -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
28
api/page_title.go
Normal 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
|
||||
}
|
@ -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")
|
||||
|
86
api/smtp_email_notification.go
Normal file
86
api/smtp_email_notification.go
Normal 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
|
||||
}
|
@ -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
36
api/utils_html.go
Normal 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
|
||||
}
|
@ -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)
|
||||
|
38
db/20190213033530-email-notifications.sql
Normal file
38
db/20190213033530-email-notifications.sql
Normal 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 '';
|
@ -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>
|
||||
|
||||
|
@ -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;
|
||||
|
104
templates/email-notification.txt
Normal file
104
templates/email-notification.txt
Normal 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>
|
Loading…
Reference in New Issue
Block a user