From 04d3541de1cf042b462e742ecf3f938fdee78116 Mon Sep 17 00:00:00 2001 From: Dmitry Verkhoturov Date: Mon, 6 Apr 2020 02:06:41 +0200 Subject: [PATCH] add admin email notifications on new comments --- README.md | 1 + backend/app/cmd/server.go | 5 ++ backend/app/notify/email.go | 127 ++++++++++++++++++++++--------- backend/app/notify/email_test.go | 60 +++++++++++++-- compose-dev-backend.yml | 2 + docs/email.md | 3 + 6 files changed, 153 insertions(+), 45 deletions(-) diff --git a/README.md b/README.md index c67bb861..3edc8c64 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,7 @@ _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 | | smtp.host | SMTP_HOST | | SMTP host | | smtp.port | SMTP_PORT | | SMTP port | | smtp.username | SMTP_USERNAME | | SMTP user name | diff --git a/backend/app/cmd/server.go b/backend/app/cmd/server.go index de5599e9..09897ad7 100644 --- a/backend/app/cmd/server.go +++ b/backend/app/cmd/server.go @@ -200,6 +200,7 @@ type NotifyGroup struct { Email struct { 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"` } `group:"email" namespace:"email" env-namespace:"EMAIL"` } @@ -786,6 +787,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, diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go index a2c3798d..39144492 100644 --- a/backend/app/notify/email.go +++ b/backend/app/notify/email.go @@ -26,6 +26,7 @@ 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 TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function } @@ -89,6 +90,7 @@ type msgTmplData struct { PostTitle string Email string UnsubscribeLink string + ForAdmin bool } // verifyTmplData store data for verification message template execution @@ -134,8 +136,13 @@ const (

Remark42

+ {{- if .ForAdmin}} +
New comment from {{.UserName}} on your site {{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
+ {{- else }}
New reply from {{.UserName}} on your comment{{if .PostTitle}} to «{{.PostTitle}}»{{ end }}
+ {{- end }}
+ {{- if .ParentCommentText}}
{{.ParentUserName}} @@ -145,6 +152,7 @@ const (
{{.ParentCommentText}}
+ {{- end }}
@@ -156,9 +164,11 @@ const (
- Sent to {{.Email}} for {{.ParentUserName}} + Sent to {{.Email}}{{if not .ForAdmin}} for {{.ParentUserName}}{{ end }}
+ {{- if .UnsubscribeLink}} Unsubscribe + {{- end }}
[{{.CommentDate.Format "02.01.2006 at 15:04"}}]
@@ -177,10 +187,10 @@ const (

Remark42

Confirmation for {{.User}} on site {{.Site}}

- {{if .SubscribeURL}} + {{- if .SubscribeURL}}

Click here to subscribe to email notifications

Alternatively, you can use code below for subscription.

- {{ end }} + {{- end }}

TOKEN

Copy and paste this text into “token” field on comments page

@@ -239,22 +249,24 @@ func (e *Email) Send(ctx context.Context, req Request) (err error) { } var emails []emailMessage - - email, err := e.createUserEmail(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( - ctx, - func() error { - return e.sendMessage(email) - })) + + emailMsg, err := e.createUserEmail(req) + errs = multierror.Append(errs, errors.Wrap(err, "problem creating email notification on comment for user")) + if emailMsg != nil { + emails = append(emails, *emailMsg) + } + + emailMsg, err = e.createAdminEmail(req) + errs = multierror.Append(errs, errors.Wrap(err, "problem creating email notification on comment for admin")) + if emailMsg != nil { + emails = append(emails, *emailMsg) + } + + for _, msg := range emails { + err = repeater.NewDefault(5, time.Millisecond*250). + Do(ctx, func() error { return e.sendMessage(msg) }) + errs = multierror.Append(errs, err) } return errs.ErrorOrNil() } @@ -285,7 +297,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 } @@ -294,6 +306,29 @@ 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) { + if req.Verification.Token != "" { + // don't notify admin on verification request + return nil, nil + } + if e.AdminEmail == "" { + // don't send anything if notifications to admin 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 @@ -312,37 +347,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() diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go index f05784a6..5a441c54 100644 --- a/backend/app/notify/email_test.go +++ b/backend/app/notify/email_test.go @@ -100,15 +100,17 @@ func TestEmailSendErrors(t *testing.T) { e.verifyTmpl, err = template.New("test").Parse("{{.Test}}") assert.NoError(t, err) - assert.EqualError(t, e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}), - "error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData") + err = e.Send(context.Background(), Request{Email: "bad@example.org", Verification: VerificationMetadata{Token: "some"}}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error executing template to build verification message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.verifyTmplData") e.verifyTmpl, err = template.New("test").Parse(defaultEmailVerificationTemplate) assert.NoError(t, err) e.msgTmpl, err = template.New("test").Parse("{{.Test}}") assert.NoError(t, err) - assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}), - "error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData") + err = e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error executing template to build comment reply message: template: test:1:2: executing \"test\" at <.Test>: can't evaluate field Test in type notify.msgTmplData") e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate) assert.NoError(t, err) @@ -118,8 +120,9 @@ func TestEmailSendErrors(t *testing.T) { "sending message to \"bad@example.org\" aborted due to canceled context") e.smtp = &fakeTestSMTP{} - assert.EqualError(t, e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}), - "error creating token for unsubscribe link: token generation error") + err = e.Send(context.Background(), Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "error"}}, Email: "bad@example.org"}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "error creating token for unsubscribe link: token generation error") e.msgTmpl, err = template.New("test").Parse(defaultEmailTemplate) assert.NoError(t, err) } @@ -187,7 +190,7 @@ func TestEmail_Send(t *testing.T) { 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: "test@example.org", } @@ -196,7 +199,7 @@ func TestEmail_Send(t *testing.T) { assert.Equal(t, 1, fakeSmtp.readQuitCount()) assert.Equal(t, "test@example.org", 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: from@example.org To: test@example.org @@ -209,6 +212,47 @@ List-Unsubscribe: