Skip to content

Commit

Permalink
add admin email notifications on new comments
Browse files Browse the repository at this point in the history
  • Loading branch information
paskal committed Apr 6, 2020
1 parent 3956026 commit df65e20
Show file tree
Hide file tree
Showing 6 changed files with 152 additions and 30 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,8 @@ _this is the recommended way to run remark42_
| notify.telegram.timeout | NOTIFY_TELEGRAM_TIMEOUT | `5s` | telegram timeout |
| notify.email.fromAddress | NOTIFY_EMAIL_FROM | | from email address |
| notify.email.verification_subj | NOTIFY_EMAIL_VERIFICATION_SUBJ | `Email verification` | verification message subject |
| notify.email.notify_admin | NOTIFY_EMAIL_ADMIN | `false` | notify admin on new comments via ADMIN_SHARED_EMAIL |
| notify.email.notify_admin_on_replies | NOTIFY_EMAIL_ADMIN_ON_REPLIES | `false` | notify admin on replies to comments as well (default is only to top-level comments) |
| smtp.host | SMTP_HOST | | SMTP host |
| smtp.port | SMTP_PORT | | SMTP port |
| smtp.username | SMTP_USERNAME | | SMTP user name |
Expand Down
17 changes: 12 additions & 5 deletions backend/app/cmd/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,8 +198,10 @@ type NotifyGroup struct {
API string `long:"api" env:"API" default:"https://api.telegram.org/bot" description:"telegram api prefix"`
} `group:"telegram" namespace:"telegram" env-namespace:"TELEGRAM"`
Email struct {
From string `long:"fromAddress" env:"FROM" description:"from email address"`
VerificationSubject string `long:"verification_subj" env:"VERIFICATION_SUBJ" description:"verification message subject"`
From string `long:"fromAddress" env:"FROM" description:"from email address"`
VerificationSubject string `long:"verification_subj" env:"VERIFICATION_SUBJ" description:"verification message subject"`
AdminNotifications bool `long:"notify_admin" env:"ADMIN" description:"notify admin on new comments via ADMIN_SHARED_EMAIL"`
AdminNotifyOnReplies bool `long:"notify_admin_on_replies" env:"ADMIN_ON_REPLIES" description:"notify admin on replies to comments as well (default is only to top-level comments)"`
} `group:"email" namespace:"email" env-namespace:"EMAIL"`
}

Expand Down Expand Up @@ -764,9 +766,10 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
destinations = append(destinations, tg)
case "email":
emailParams := notify.EmailParams{
From: s.Notify.Email.From,
VerificationSubject: s.Notify.Email.VerificationSubject,
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
From: s.Notify.Email.From,
VerificationSubject: s.Notify.Email.VerificationSubject,
UnsubscribeURL: s.RemarkURL + "/email/unsubscribe.html",
AdminNotifyOnReplies: s.Notify.Email.AdminNotifyOnReplies,
// TODO: uncomment after #560 frontend part is ready and URL is known
// SubscribeURL: s.RemarkURL + "/subscribe.html?token=",
TokenGenFn: func(userID, email, site string) (string, error) {
Expand All @@ -786,6 +789,10 @@ func (s *ServerCommand) makeNotify(dataStore *service.DataStore, authenticator *
return tkn, nil
},
}
// enable admin notifications only if admin email is set
if s.Notify.Email.AdminNotifications && s.Admin.Shared.Email != "" {
emailParams.AdminEmail = s.Admin.Shared.Email
}
smtpParams := notify.SmtpParams{
Host: s.SMTP.Host,
Port: s.SMTP.Port,
Expand Down
108 changes: 86 additions & 22 deletions backend/app/notify/email.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ type EmailParams struct {
VerificationTemplate string // verification message template
SubscribeURL string // full subscribe handler URL
UnsubscribeURL string // full unsubscribe handler URL
AdminEmail string // admin email for sending notifications about new messages
AdminNotifyOnReplies bool // if we should send all messages to admin or only first-level comments

TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
}
Expand Down Expand Up @@ -89,6 +91,7 @@ type msgTmplData struct {
PostTitle string
Email string
UnsubscribeLink string
ForAdmin bool
}

// verifyTmplData store data for verification message template execution
Expand Down Expand Up @@ -134,8 +137,13 @@ const (
<body>
<div style="font-family: Helvetica, Arial, sans-serif; font-size: 18px; width: 100%; max-width: 640px; margin: auto;">
<h1 style="text-align: center; position: relative; color: #4fbbd6; margin-top: 10px; margin-bottom: 10px;">Remark42</h1>
{{- if .ForAdmin}}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- else }}
<div style="font-size: 16px; text-align: center; margin-bottom: 10px; color:#000!important;">New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}</div>
{{- end }}
<div style="background-color: #eee; padding: 15px 20px 20px 20px; border-radius: 3px;">
{{- if .ParentCommentText}}
<div style="margin-bottom: 12px; line-height: 24px; word-break: break-all;">
<img src="{{.ParentUserPicture}}" style="width: 24px; height: 24px; display: inline; vertical-align: middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
<span style="font-size: 14px; font-weight: bold; color: #777">{{.ParentUserName}}</span>
Expand All @@ -145,6 +153,7 @@ const (
<div style="font-size: 14px; color:#333!important; padding: 0 14px 0 2px; border-radius: 3px; line-height: 1.4;">
{{.ParentCommentText}}
</div>
{{- end }}
<div style="padding-left: 20px; border-left: 1px dotted rgba(0,0,0,0.15); margin-top: 15px; padding-top: 5px;">
<div style="margin-bottom: 12px;" line-height: 24px;word-break: break-all;>
<img src="{{.UserPicture}}" style="width: 24px; height: 24px; display:inline; vertical-align:middle; margin: 0 8px 0 0; border-radius: 3px; background-color: #ccc;"/>
Expand All @@ -156,9 +165,11 @@ const (
</div>
</div>
<div style="text-align: center; font-size: 14px; margin-top: 32px;">
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a> for {{.ParentUserName}}</i>
<i style="color: #000!important;">Sent to <a style="color:inherit; text-decoration: none" href="mailto:{{.Email}}">{{.Email}}</a>{{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}</i>
<div style="margin: auto; width: 150px; border-top: 1px solid rgba(0, 0, 0, 0.15); padding-top: 15px; margin-top: 15px;"></div>
{{- if .UnsubscribeLink}}
<a style="color: #0aa;" href="{{.UnsubscribeLink}}">Unsubscribe</a>
{{- end }}
<!-- This is hack for remove collapser in Gmail which can collapse end of the message -->
<div style="opacity: 0;font-size: 1;">[{{.CommentDate.Format "02.01.2006 at 15:04"}}]</div>
</div>
Expand All @@ -177,10 +188,10 @@ const (
<div style="text-align: center; font-family: Helvetica, Arial, sans-serif; font-size: 18px;">
<h1 style="position: relative; color: #4fbbd6; margin-top: 0.2em;">Remark42</h1>
<p style="position: relative; max-width: 20em; margin: 0 auto 1em auto; line-height: 1.4em; color:#000!important;">Confirmation for <b>{{.User}}</b> on site <b>{{.Site}}</b></p>
{{if .SubscribeURL}}
{{- if .SubscribeURL}}
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;"><a href="{{.SubscribeURL}}{{.Token}}">Click here to subscribe to email notifications</a></p>
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">Alternatively, you can use code below for subscription.</p>
{{ end }}
{{- end }}
<div style="background-color: #eee; max-width: 20em; margin: 0 auto; border-radius: 0.4em; padding: 0.5em;">
<p style="position: relative; margin: 0 0 0.5em 0;color:#000!important;">TOKEN</p>
<p style="position: relative; font-size: 0.7em; opacity: 0.8;"><i style="color:#000!important;">Copy and paste this text into “token” field on comments page</i></p>
Expand Down Expand Up @@ -248,6 +259,14 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) {
emails = append(emails, *email)
}

email, err = e.createAdminEmail(req)
if err != nil {
return err
}
if email != nil {
emails = append(emails, *email)
}

errs := new(multierror.Error)
for _, email := range emails {
errs = multierror.Append(errs, repeater.NewDefault(5, time.Millisecond*250).Do(
Expand Down Expand Up @@ -285,7 +304,7 @@ func (e *Email) createUserEmail(req Request) (*emailMessage, error) {
return nil, nil
}
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
msg, err = e.buildMessageFromRequest(req)
msg, err = e.buildMessageFromRequest(req, false)
if err != nil {
return nil, err
}
Expand All @@ -294,6 +313,33 @@ func (e *Email) createUserEmail(req Request) (*emailMessage, error) {
return &emailMessage{from: e.From, to: req.Email, message: msg}, nil
}

// construct email for admin if it's enabled
func (e *Email) createAdminEmail(req Request) (*emailMessage, error) {
switch {
case req.Verification.Token != "":
// don't notify admin on verification request
return nil, nil
case e.AdminEmail == "":
// don't send anything if notifications to admin are disabled
return nil, nil
case req.Comment.ParentID != "" && !e.AdminNotifyOnReplies:
// don't send anything if it's a reply to someone's comment,
// and notifications for replies are disabled
return nil, nil
}

var msg string
var err error

log.Printf("[DEBUG] send admin notification via %s, comment id %s", e, req.Comment.ID)
msg, err = e.buildMessageFromRequest(req, true)
if err != nil {
return nil, err
}

return &emailMessage{from: e.From, to: e.AdminEmail, message: msg}, nil
}

// buildVerificationMessage generates verification email message based on given input
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
subject := e.VerificationSubject
Expand All @@ -312,37 +358,55 @@ func (e *Email) buildVerificationMessage(user, email, token, site string) (strin
}

// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
func (e *Email) buildMessageFromRequest(req Request) (string, error) {
func (e *Email) buildMessageFromRequest(req Request, forAdmin bool) (string, error) {
subject := "New reply to your comment"
if forAdmin {
subject = "New comment to your site"
}
if req.Comment.PostTitle != "" {
subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
}

token, err := e.TokenGenFn(req.parent.User.ID, req.Email, req.Comment.Locator.SiteID)
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
if err != nil {
return "", errors.Wrapf(err, "error creating token for unsubscribe link")
}
unsubscribeLink := e.UnsubscribeURL + "?site=" + req.Comment.Locator.SiteID + "&tkn=" + token
if forAdmin {
unsubscribeLink = ""
}

email := req.Email
if forAdmin {
email = e.AdminEmail
}

commentUrlPrefix := req.Comment.Locator.URL + uiNav
msg := bytes.Buffer{}
err = e.msgTmpl.Execute(&msg, msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentLink: commentUrlPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
ParentUserName: req.parent.User.Name,
ParentUserPicture: req.parent.User.Picture,
ParentCommentText: req.parent.Text,
ParentCommentLink: commentUrlPrefix + req.parent.ID,
ParentCommentDate: req.parent.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: req.Email,
UnsubscribeLink: unsubscribeLink,
})
tmplData := msgTmplData{
UserName: req.Comment.User.Name,
UserPicture: req.Comment.User.Picture,
CommentText: req.Comment.Text,
CommentLink: commentUrlPrefix + req.Comment.ID,
CommentDate: req.Comment.Timestamp,
PostTitle: req.Comment.PostTitle,
Email: email,
UnsubscribeLink: unsubscribeLink,
ForAdmin: forAdmin,
}
// in case of message to admin, parent message might be empty
if req.Comment.ParentID != "" {
tmplData.ParentUserName = req.parent.User.Name
tmplData.ParentUserPicture = req.parent.User.Picture
tmplData.ParentCommentText = req.parent.Text
tmplData.ParentCommentLink = commentUrlPrefix + req.parent.ID
tmplData.ParentCommentDate = req.parent.Timestamp
}
err = e.msgTmpl.Execute(&msg, tmplData)
if err != nil {
return "", errors.Wrapf(err, "error executing template to build comment reply message")
}
return e.buildMessage(subject, msg.String(), req.Email, "text/html", unsubscribeLink)
return e.buildMessage(subject, msg.String(), email, "text/html", unsubscribeLink)
}

// buildMessage generates email message to send using net/smtp.Data()
Expand Down
47 changes: 44 additions & 3 deletions backend/app/notify/email_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,15 +179,15 @@ func TestEmailSendClientError(t *testing.T) {
}

func TestEmail_Send(t *testing.T) {
email, err := NewEmail(EmailParams{From: "[email protected]"}, SmtpParams{})
email, err := NewEmail(EmailParams{From: "[email protected]", AdminEmail: "[email protected]"}, SmtpParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSmtp := fakeTestSMTP{}
email.smtp = &fakeSmtp
email.TokenGenFn = TokenGenFn
email.UnsubscribeURL = "https://remark42.com/api/v1/email/unsubscribe"
req := Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "test_title"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
Email: "[email protected]",
}
Expand All @@ -196,7 +196,7 @@ func TestEmail_Send(t *testing.T) {
assert.Equal(t, 1, fakeSmtp.readQuitCount())
assert.Equal(t, "[email protected]", fakeSmtp.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req)
res, err := email.buildMessageFromRequest(req, false)
assert.NoError(t, err)
assert.Contains(t, res, `From: [email protected]
To: [email protected]
Expand All @@ -209,6 +209,47 @@ List-Unsubscribe: <https://remark42.com/api/v1/email/unsubscribe?site=&tkn=token
Date: `)
}

func TestEmail_SendAdmin(t *testing.T) {
email, err := NewEmail(EmailParams{From: "[email protected]", AdminEmail: "[email protected]", AdminNotifyOnReplies: true}, SmtpParams{})
assert.NoError(t, err)
assert.NotNil(t, email)
fakeSmtp := fakeTestSMTP{}
email.smtp = &fakeSmtp
email.TokenGenFn = TokenGenFn
req := Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, ParentID: "1", PostTitle: "test_title"},
parent: store.Comment{ID: "1", User: store.User{ID: "999", Name: "parent_user"}},
}
assert.NoError(t, email.Send(context.TODO(), req))
assert.Equal(t, "[email protected]", fakeSmtp.readMail())
assert.Equal(t, 1, fakeSmtp.readQuitCount())
assert.Equal(t, "[email protected]", fakeSmtp.readRcpt())
// test buildMessageFromRequest separately for message text
res, err := email.buildMessageFromRequest(req, true)
assert.NoError(t, err)
assert.Contains(t, res, `From: [email protected]
To: [email protected]
Subject: New comment to your site for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
// send email to admin without parent set
req = Request{
Comment: store.Comment{ID: "999", User: store.User{ID: "1", Name: "test_user"}, PostTitle: "test_title"},
}
assert.NoError(t, email.Send(context.TODO(), req))
res, err = email.buildMessageFromRequest(req, true)
assert.NoError(t, err)
assert.Contains(t, res, `From: [email protected]
To: [email protected]
Subject: New comment to your site for "test_title"
Content-Transfer-Encoding: quoted-printable
MIME-version: 1.0
Content-Type: text/html; charset="UTF-8"
Date: `)
}

func TestEmail_SendVerification(t *testing.T) {
email, err := NewEmail(EmailParams{From: "[email protected]"}, SmtpParams{})
assert.NoError(t, err)
Expand Down
3 changes: 3 additions & 0 deletions compose-dev-backend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ services:
- NOTIFY_TELEGRAM_TOKEN
- NOTIFY_TELEGRAM_CHAN
- NOTIFY_EMAIL_FROM
- ADMIN_SHARED_EMAIL
- NOTIFY_EMAIL_ADMIN
- NOTIFY_EMAIL_ADMIN_ON_REPLIES
- SMTP_HOST
- SMTP_USERNAME
- SMTP_PASSWORD
Expand Down
5 changes: 5 additions & 0 deletions docs/email.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,11 @@ Here is the list of variables which affect email notifications:
NOTIFY_TYPE
NOTIFY_EMAIL_FROM
NOTIFY_EMAIL_VERIFICATION_SUBJ
# for administrator notifications for new comments on their site
ADMIN_SHARED_EMAIL
NOTIFY_EMAIL_ADMIN
# for administrator notifications not only to top-level comments, but to replies to any comments as well
NOTIFY_EMAIL_ADMIN_ON_REPLIES
```

After `SMTP_` variables are set, you can allow email notifications by setting these two variables:
Expand Down

0 comments on commit df65e20

Please sign in to comment.