diff --git a/backend/app/notify/email.go b/backend/app/notify/email.go
new file mode 100644
index 00000000..158b7bdb
--- /dev/null
+++ b/backend/app/notify/email.go
@@ -0,0 +1,339 @@
+package notify
+
+import (
+ "bytes"
+ "context"
+ "crypto/tls"
+ "fmt"
+ "io"
+ "net"
+ "net/smtp"
+ "text/template"
+ "time"
+
+ log "github.com/go-pkgz/lgr"
+ "github.com/go-pkgz/repeater"
+ "github.com/hashicorp/go-multierror"
+ "github.com/pkg/errors"
+)
+
+// EmailParams contain settings for email notifications
+type EmailParams struct {
+ From string // From email field
+ MsgTemplate string // request message template
+ VerificationSubject string // verification message subject
+ VerificationTemplate string // verification message template
+ BufferSize int // email send buffer size
+ FlushDuration time.Duration // maximum time after which email will me sent, 30s by default
+}
+
+// SmtpParams contain settings for smtp server connection
+type SmtpParams struct {
+ Host string // SMTP host
+ Port int // SMTP port
+ TLS bool // TLS auth
+ Username string // user name
+ Password string // password
+ TimeOut time.Duration // TCP connection timeout
+}
+
+// Email implements notify.Destination for email
+type Email struct {
+ EmailParams
+ SmtpParams
+
+ smtp smtpClientCreator
+ msgTmpl *template.Template // parsed request message template
+ verifyTmpl *template.Template // parsed verification message template
+}
+
+// default email client implementation
+type emailClient struct{ smtpClientCreator }
+
+// smtpClient interface defines subset of net/smtp used by email client
+type smtpClient interface {
+ Mail(string) error
+ Auth(smtp.Auth) error
+ Rcpt(string) error
+ Data() (io.WriteCloser, error)
+ Quit() error
+ Close() error
+}
+
+// smtpClientCreator interface defines function for creating new smtpClients
+type smtpClientCreator interface {
+ Create(SmtpParams) (smtpClient, error)
+}
+
+type emailMessage struct {
+ from string
+ to string
+ message string
+}
+
+// msgTmplData store data for message from request template execution
+type msgTmplData struct {
+ From string
+ To string
+ Orig string
+ Link string
+ PostTitle string
+}
+
+// verifyTmplData store data for verification message template execution
+type verifyTmplData struct {
+ User string
+ Email string
+ Token string
+ Site string
+}
+
+const (
+ defaultVerificationSubject = "Email verification"
+ defaultEmailTimeout = 10 * time.Second
+ defaultFlushDuration = time.Second * 30
+ defaultEmailTemplate = `{{.From}}{{if .To}} → {{.To}}{{end}}
+
+{{.Orig}}
+
+↦ {{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}
+`
+ defaultEmailVerificationTemplate = `Confirmation for {{.User}} {{.Email}}, site {{.Site}}
+
+Token: {{.Token}}
+`
+)
+
+// NewEmail makes new Email object, returns it even in case of problems
+// (e.MsgTemplate parsing error or error while testing smtp connection by credentials provided in emailParams)
+func NewEmail(emailParams EmailParams, smtpParams SmtpParams) (*Email, error) {
+ var err error
+ // set up Email emailParams
+ res := Email{EmailParams: emailParams}
+ if res.FlushDuration <= 0 {
+ res.FlushDuration = defaultFlushDuration
+ }
+ if res.BufferSize <= 0 {
+ res.BufferSize = 1
+ }
+ if res.MsgTemplate == "" {
+ res.MsgTemplate = defaultEmailTemplate
+ }
+ if res.VerificationTemplate == "" {
+ res.VerificationTemplate = defaultEmailVerificationTemplate
+ }
+ if res.VerificationSubject == "" {
+ res.VerificationSubject = defaultVerificationSubject
+ }
+
+ // set up SMTP emailParams
+ res.smtp = &emailClient{}
+ res.SmtpParams = smtpParams
+ if res.TimeOut <= 0 {
+ res.TimeOut = defaultEmailTimeout
+ }
+
+ log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
+ res.Host, res.Username, res.TimeOut)
+
+ // initialise templates
+ res.msgTmpl, err = template.New("messageFromRequest").Parse(res.MsgTemplate)
+ if err != nil {
+ return &res, errors.Wrapf(err, "can't parse message template")
+ }
+ res.verifyTmpl, err = template.New("messageFromRequest").Parse(res.VerificationTemplate)
+ if err != nil {
+ return &res, errors.Wrapf(err, "can't parse verification template")
+ }
+
+ // establish test connection
+ testSmtpClient, err := res.smtp.Create(res.SmtpParams)
+ if err != nil {
+ return &res, errors.Wrapf(err, "can't establish test connection")
+ }
+ if err = testSmtpClient.Quit(); err != nil {
+ log.Printf("[WARN] failed to send quit command to %s:%d, %v", res.Host, res.Port, err)
+ if err = testSmtpClient.Close(); err != nil {
+ return &res, errors.Wrapf(err, "can't close test smtp connection")
+ }
+ }
+ return &res, err
+}
+
+// Send email about reply to Request.Email if it's set, otherwise do nothing and return nil, thread safe
+// do not returns sending error, only following:
+// 1. (likely impossible) template execution error from email message creation from Request
+// 2. message dropped without sending in case of closed ctx
+func (e *Email) Send(ctx context.Context, req Request) (err error) {
+ if req.Email == "" {
+ // this means we can't send this request via Email
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return errors.Errorf("sending message to %q aborted due to canceled context", req.Email)
+ default:
+ }
+ var msg string
+
+ if req.Verification.Token != "" {
+ log.Printf("[DEBUG] send verification via %s, user %s", e, req.Verification.User)
+ msg, err = e.buildVerificationMessage(req.Verification.User, req.Email, req.Verification.Token, req.Verification.Locator.SiteID)
+ if err != nil {
+ return err
+ }
+ }
+
+ if req.Comment.ID != "" {
+ if req.parent.User == req.Comment.User {
+ // don't send anything if if user replied to their own Comment
+ return nil
+ }
+ log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
+ msg, err = e.buildMessageFromRequest(req, req.Email)
+ if err != nil {
+ return err
+ }
+ }
+
+ return e.sendMessage(ctx, emailMessage{from: e.From, to: req.Email, message: msg})
+}
+
+// buildVerificationMessage generates verification email message based on given input
+func (e *Email) buildVerificationMessage(user, address, token, site string) (string, error) {
+ subject := e.VerificationSubject
+ msg := bytes.Buffer{}
+ err := e.verifyTmpl.Execute(&msg, verifyTmplData{user, address, token, site})
+ if err != nil {
+ return "", errors.Wrapf(err, "error executing template to build verifying message from request")
+ }
+ return e.buildMessage(subject, msg.String(), address, "text/html"), nil
+}
+
+// buildMessage generates email message to send using net/smtp.Data()
+func (e *Email) buildMessage(subject, body, to, contentType string) (message string) {
+ message += fmt.Sprintf("From: %s\n", e.From)
+ message += fmt.Sprintf("To: %s\n", to)
+ message += fmt.Sprintf("Subject: %s\n", subject)
+ if contentType != "" {
+ message += fmt.Sprintf("MIME-version: 1.0;\nContent-Type: %s; charset=\"UTF-8\";\n", contentType)
+ }
+ message += "\n" + body
+ return message
+}
+
+// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
+func (e *Email) buildMessageFromRequest(req Request, to string) (string, error) {
+ subject := "New comment"
+ if req.Comment.PostTitle != "" {
+ subject += fmt.Sprintf(" for \"%s\"", req.Comment.PostTitle)
+ }
+ msg := bytes.Buffer{}
+ err := e.msgTmpl.Execute(&msg, msgTmplData{
+ req.Comment.User.Name,
+ req.parent.User.Name,
+ req.Comment.Orig,
+ req.Comment.Locator.URL + uiNav + req.Comment.ID,
+ req.Comment.PostTitle,
+ })
+ if err != nil {
+ return "", errors.Wrapf(err, "error executing template to build message from request")
+ }
+ return e.buildMessage(subject, msg.String(), to, "text/html"), nil
+}
+
+// sendMessage sends messages to server in a new connection, closing the connection after finishing.
+// Thread safe.
+func (e *Email) sendMessage(ctx context.Context, m emailMessage) error {
+ if e.smtp == nil {
+ return errors.New("sendMessage called without smtpClient set")
+ }
+ smtpClient, err := e.smtp.Create(e.SmtpParams)
+ if err != nil {
+ return errors.Wrap(err, "failed to make smtp Create")
+ }
+
+ errs := new(multierror.Error)
+
+ err = repeater.NewDefault(5, time.Millisecond*250).Do(ctx, func() error {
+ if err := smtpClient.Mail(m.from); err != nil {
+ return errors.Wrapf(err, "bad from address %q", m.from)
+ }
+ if err := smtpClient.Rcpt(m.to); err != nil {
+ return errors.Wrapf(err, "bad to address %q", m.to)
+ }
+
+ writer, err := smtpClient.Data()
+ if err != nil {
+ return errors.Wrap(err, "can't make email writer")
+ }
+ defer func() {
+ if err = writer.Close(); err != nil {
+ log.Printf("[WARN] can't close smtp body writer, %v", err)
+ }
+ }()
+
+ buf := bytes.NewBufferString(m.message)
+ if _, err = buf.WriteTo(writer); err != nil {
+ return errors.Wrapf(err, "failed to send email body to %q", m.to)
+ }
+ return nil
+ })
+ if err != nil {
+ errs = multierror.Append(errs, errors.Wrapf(err, "can't send message to %s", m.to))
+ }
+
+ if err := smtpClient.Quit(); err != nil {
+ log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
+ if err := smtpClient.Close(); err != nil {
+ log.Printf("[WARN] can't close smtp connection, %v", err)
+ errs = multierror.Append(errs, err)
+ }
+ }
+ return errors.Wrapf(errs.ErrorOrNil(), "problems with sending message")
+}
+
+// String representation of Email object
+func (e *Email) String() string {
+ return fmt.Sprintf("email: from %q using '%s'@'%s':%d", e.From, e.Username, e.Host, e.Port)
+}
+
+// Create establish SMTP connection with server using credentials in smtpClientWithCreator.SmtpParams
+// and returns pointer to it. Thread safe.
+func (s *emailClient) Create(params SmtpParams) (smtpClient, error) {
+ var c *smtp.Client
+ srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
+ if params.TLS {
+ tlsConf := &tls.Config{
+ InsecureSkipVerify: false,
+ ServerName: params.Host,
+ }
+ conn, err := tls.Dial("tcp", srvAddress, tlsConf)
+ if err != nil {
+ return nil, errors.Wrapf(err, "failed to dial smtp tls to %s", srvAddress)
+ }
+ if c, err = smtp.NewClient(conn, params.Host); err != nil {
+ return nil, errors.Wrapf(err, "failed to make smtp client for %s", srvAddress)
+ }
+ return c, nil
+ }
+
+ conn, err := net.DialTimeout("tcp", srvAddress, params.TimeOut)
+ if err != nil {
+ return nil, errors.Wrapf(err, "timeout connecting to %s", srvAddress)
+ }
+
+ c, err = smtp.NewClient(conn, srvAddress)
+ if err != nil {
+ return nil, errors.Wrap(err, "failed to dial")
+ }
+
+ if params.Username != "" && params.Password != "" {
+ auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
+ if err := c.Auth(auth); err != nil {
+ return nil, errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
+ }
+ }
+
+ return c, nil
+}
diff --git a/backend/app/notify/email_test.go b/backend/app/notify/email_test.go
new file mode 100644
index 00000000..7c977a79
--- /dev/null
+++ b/backend/app/notify/email_test.go
@@ -0,0 +1,295 @@
+package notify
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "io"
+ "net/smtp"
+ "sync"
+ "testing"
+ "text/template"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+
+ "github.com/umputun/remark/backend/app/store"
+)
+
+func TestEmailNew(t *testing.T) {
+ var testSet = []struct {
+ name string
+ template bool
+ err bool
+ errText string
+ emailParams EmailParams
+ smtpParams SmtpParams
+ }{
+ {name: "with connection error", template: true, err: true},
+ {name: "with template parse error",
+ err: true, errText: "can't parse message template: template: messageFromRequest:1: unexpected unclosed action in command",
+ emailParams: EmailParams{
+ From: "test@from",
+ MsgTemplate: "{{",
+ BufferSize: 10,
+ FlushDuration: time.Second,
+ }},
+ {name: "with verification template parse error",
+ err: true, errText: "can't parse verification template: template: messageFromRequest:1: unexpected unclosed action in command",
+ template: true,
+ emailParams: EmailParams{
+ VerificationTemplate: "{{",
+ },
+ smtpParams: SmtpParams{
+ Host: "test@host",
+ Port: 1000,
+ TLS: true,
+ Username: "test@username",
+ Password: "test@password",
+ TimeOut: time.Second,
+ },
+ },
+ }
+ for _, d := range testSet {
+ d := d // capture range variable
+ t.Run(d.name, func(t *testing.T) {
+ email, err := NewEmail(d.emailParams, d.smtpParams)
+
+ if d.err && d.errText == "" {
+ assert.Error(t, err)
+ } else if d.err && d.errText != "" {
+ assert.EqualError(t, err, d.errText)
+ } else {
+ assert.NoError(t, err)
+ }
+
+ assert.NotNil(t, email, "email returned")
+ if d.template {
+ assert.NotNil(t, email.msgTmpl, "e.template is set")
+ } else {
+ assert.Nil(t, email.msgTmpl, "e.template is not set")
+ }
+ if d.emailParams.MsgTemplate == "" {
+ assert.Equal(t, defaultEmailTemplate, email.EmailParams.MsgTemplate, "empty emailParams.MsgTemplate changed to default")
+ } else {
+ assert.Equal(t, d.emailParams.MsgTemplate, email.EmailParams.MsgTemplate, "emailParams.MsgTemplate unchanged after creation")
+ }
+ if d.emailParams.FlushDuration == 0 {
+ assert.Equal(t, defaultFlushDuration, email.EmailParams.FlushDuration, "empty emailParams.FlushDuration changed to default")
+ } else {
+ assert.Equal(t, d.emailParams.FlushDuration, email.EmailParams.FlushDuration, "emailParams.FlushDuration unchanged after creation")
+ }
+ if d.emailParams.BufferSize == 0 {
+ assert.Equal(t, 1, email.EmailParams.BufferSize, "empty emailParams.BufferSize changed to default")
+ } else {
+ assert.Equal(t, d.emailParams.BufferSize, email.EmailParams.BufferSize, "emailParams.BufferSize unchanged after creation")
+ }
+ assert.Equal(t, d.emailParams.From, email.EmailParams.From, "emailParams.From unchanged after creation")
+ if d.smtpParams.TimeOut == 0 {
+ assert.Equal(t, defaultEmailTimeout, email.TimeOut, "empty emailParams.TimeOut changed to default")
+ } else {
+ assert.Equal(t, d.smtpParams.TimeOut, email.TimeOut, "emailParams.TimOut unchanged after creation")
+ }
+ assert.Equal(t, d.smtpParams.Host, email.Host, "emailParams.Host unchanged after creation")
+ assert.Equal(t, d.smtpParams.Username, email.Username, "emailParams.Username unchanged after creation")
+ assert.Equal(t, d.smtpParams.Password, email.Password, "emailParams.Password unchanged after creation")
+ assert.Equal(t, d.smtpParams.Port, email.Port, "emailParams.Port unchanged after creation")
+ assert.Equal(t, d.smtpParams.TLS, email.TLS, "emailParams.TLS unchanged after creation")
+ })
+ }
+}
+
+func TestEmailSendErrors(t *testing.T) {
+ var err error
+ e := Email{EmailParams: EmailParams{FlushDuration: time.Second}}
+
+ 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 verifying message from request: 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 message from request: 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)
+
+ ctx, cancel := context.WithCancel(context.Background())
+ cancel()
+ assert.EqualError(t, e.Send(ctx, Request{Comment: store.Comment{ID: "999"}, parent: store.Comment{User: store.User{ID: "test"}}, Email: "bad@example.org"}),
+ "sending message to \"bad@example.org\" aborted due to canceled context")
+}
+
+func TestEmailSend_ExitConditions(t *testing.T) {
+ email, err := NewEmail(EmailParams{}, SmtpParams{})
+ assert.Error(t, err, "error match expected")
+ assert.NotNil(t, email, "expecting email returned")
+ // prevent triggering e.autoFlush creation
+ emptyRequest := Request{Comment: store.Comment{ID: "999"}}
+ assert.Nil(t, email.Send(context.Background(), emptyRequest),
+ "Message without parent comment User.Email is not sent and returns nil")
+ requestWithEqualUsersWithEmails := Request{Comment: store.Comment{ID: "999"}, Email: "good_example@example.org"}
+ assert.Nil(t, email.Send(context.Background(), requestWithEqualUsersWithEmails),
+ "Message with parent comment User equals comment User is not sent and returns nil")
+}
+
+func TestEmailSendClientError(t *testing.T) {
+ var testSet = []struct {
+ name string
+ smtp *fakeTestSMTP
+ err string
+ }{
+ {name: "failed to verify receiver", smtp: &fakeTestSMTP{fail: map[string]bool{"mail": true}},
+ err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad from address \"\": failed to verify sender\n\n"},
+ {name: "failed to verify sender", smtp: &fakeTestSMTP{fail: map[string]bool{"rcpt": true}},
+ err: "problems with sending message: 1 error occurred:\n\t* can't send message to : bad to address \"\": failed to verify receiver\n\n"},
+ {name: "failed to close connection", smtp: &fakeTestSMTP{fail: map[string]bool{"quit": true, "close": true}},
+ err: "problems with sending message: 1 error occurred:\n\t* failed to close\n\n"},
+ {name: "failed to make email writer", smtp: &fakeTestSMTP{fail: map[string]bool{"data": true}},
+ err: "problems with sending message: 1 error occurred:\n\t* can't send message to : can't make email writer: failed to send\n\n"},
+ }
+ for _, d := range testSet {
+ d := d // capture range variable
+ t.Run(d.name, func(t *testing.T) {
+ e := Email{smtp: d.smtp}
+ assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), d.err,
+ "expected error for e.sendMessage")
+ })
+ }
+ e := Email{}
+ e.smtp = nil
+ assert.Error(t, e.sendMessage(context.Background(), emailMessage{}),
+ "nil e.smtp should return error")
+ e.smtp = &fakeTestSMTP{}
+ assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
+ "no error expected for e.sendMessage in normal flow")
+ e.smtp = &fakeTestSMTP{fail: map[string]bool{"quit": true}}
+ assert.NoError(t, e.sendMessage(context.Background(), emailMessage{}), "",
+ "no error expected for e.sendMessage with failed smtpClient.Quit but successful smtpClient.Close")
+ e.smtp = &fakeTestSMTP{fail: map[string]bool{"create": true}}
+ assert.EqualError(t, e.sendMessage(context.Background(), emailMessage{}), "failed to make smtp Create: failed to create client",
+ "e.send called without smtpClient set returns error")
+}
+
+func TestEmail_Send(t *testing.T) {
+ const expectedAnswer = `From: from@example.org
+To: test@example.org
+Subject: New comment for "test_title"
+MIME-version: 1.0;
+Content-Type: text/html; charset="UTF-8";
+
+test_user
+
+
+
+↦ test_title
+`
+ req := Request{Comment: store.Comment{ID: "999", User: store.User{Name: "test_user"}, PostTitle: "test_title"}, Email: "test@example.org"}
+ e, err := NewEmail(EmailParams{From: "from@example.org"}, SmtpParams{})
+ assert.Error(t, err, "connection error expected")
+ assert.NotNil(t, e)
+ fakeSmtp := fakeTestSMTP{}
+ e.smtp = &fakeSmtp
+ assert.NoError(t, e.Send(context.TODO(), req))
+ assert.Equal(t, "from@example.org", fakeSmtp.readMail())
+ assert.Equal(t, 1, fakeSmtp.readQuitCount())
+ assert.Equal(t, "test@example.org", fakeSmtp.readRcpt())
+ // test buildMessageFromRequest separately for message text
+ res, err := e.buildMessageFromRequest(req, "test@example.org")
+ assert.NoError(t, err)
+ assert.Equal(t, expectedAnswer, res)
+}
+
+type fakeTestSMTP struct {
+ fail map[string]bool
+
+ buff bytes.Buffer
+ mail, rcpt string
+ auth bool
+ close bool
+ quitCount int
+ lock sync.RWMutex
+}
+
+func (f *fakeTestSMTP) Create(SmtpParams) (smtpClient, error) {
+ if f.fail["create"] {
+ return nil, errors.New("failed to create client")
+ }
+ return f, nil
+}
+
+func (f *fakeTestSMTP) Auth(smtp.Auth) error { f.auth = true; return nil }
+
+func (f *fakeTestSMTP) Mail(m string) error {
+ f.lock.Lock()
+ f.mail = m
+ f.lock.Unlock()
+ if f.fail["mail"] {
+ return errors.New("failed to verify sender")
+ }
+ return nil
+}
+
+func (f *fakeTestSMTP) Rcpt(r string) error {
+ f.lock.Lock()
+ f.rcpt = r
+ f.lock.Unlock()
+ if f.fail["rcpt"] {
+ return errors.New("failed to verify receiver")
+ }
+ return nil
+}
+
+func (f *fakeTestSMTP) Quit() error {
+ f.lock.Lock()
+ f.quitCount++
+ f.lock.Unlock()
+ if f.fail["quit"] {
+ return errors.New("failed to quit")
+ }
+ return nil
+}
+
+func (f *fakeTestSMTP) Close() error {
+ f.close = true
+ if f.fail["close"] {
+ return errors.New("failed to close")
+ }
+ return nil
+}
+
+func (f *fakeTestSMTP) Data() (io.WriteCloser, error) {
+ if f.fail["data"] {
+ return nil, errors.New("failed to send")
+ }
+ return nopCloser{&f.buff}, nil
+}
+
+func (f *fakeTestSMTP) readRcpt() string {
+ f.lock.RLock()
+ defer f.lock.RUnlock()
+ return f.rcpt
+}
+
+func (f *fakeTestSMTP) readMail() string {
+ f.lock.RLock()
+ defer f.lock.RUnlock()
+ return f.mail
+}
+
+func (f *fakeTestSMTP) readQuitCount() int {
+ f.lock.RLock()
+ defer f.lock.RUnlock()
+ return f.quitCount
+}
+
+type nopCloser struct {
+ io.Writer
+}
+
+func (nopCloser) Close() error {
+ return nil
+}
diff --git a/backend/app/notify/notify.go b/backend/app/notify/notify.go
index 8766089e..7d07da8c 100644
--- a/backend/app/notify/notify.go
+++ b/backend/app/notify/notify.go
@@ -34,9 +34,19 @@ type Store interface {
Get(locator store.Locator, id string, user store.User) (store.Comment, error)
}
+// Request notification either about comment or about particular user verification
type Request struct {
- Comment store.Comment
- parent store.Comment
+ Comment store.Comment // if set sent notifications about new comment
+ parent store.Comment // fetched only in case Comment is set
+ Email string // if set (also) send email
+ Verification VerificationMetadata // if set sent verification notification
+}
+
+// VerificationMetadata required to send notify method verification message
+type VerificationMetadata struct {
+ Locator store.Locator // only SiteID is used
+ User string
+ Token string
}
const defaultQueueSize = 100
@@ -67,7 +77,8 @@ func (s *Service) Submit(req Request) {
if len(s.destinations) == 0 || atomic.LoadUint32(&s.closed) != 0 {
return
}
- if s.dataService != nil {
+ // parent comment is fetched only if comment is present in the Request
+ if s.dataService != nil && req.Comment.ParentID != "" {
if p, err := s.dataService.Get(req.Comment.Locator, req.Comment.ParentID, store.User{}); err == nil {
req.parent = p
}
@@ -75,7 +86,7 @@ func (s *Service) Submit(req Request) {
select {
case s.queue <- req:
default:
- log.Printf("[WARN] can't send comment notification to queue, %+v", req.Comment)
+ log.Printf("[WARN] can't send notification to queue, %+v", req.Comment)
}
}
diff --git a/backend/app/notify/notify_test.go b/backend/app/notify/notify_test.go
index bddc625b..3927d3a7 100644
--- a/backend/app/notify/notify_test.go
+++ b/backend/app/notify/notify_test.go
@@ -17,7 +17,8 @@ import (
)
func TestService_NoDestinations(t *testing.T) {
- s := NewService(nil, 1)
+ s := NewService(nil, 0)
+ assert.Equal(t, defaultQueueSize, cap(s.queue))
assert.NotNil(t, s)
s.Submit(Request{Comment: store.Comment{ID: "123"}})
s.Submit(Request{Comment: store.Comment{ID: "123"}})
diff --git a/backend/app/notify/telegram.go b/backend/app/notify/telegram.go
index 83bf532a..4d253711 100644
--- a/backend/app/notify/telegram.go
+++ b/backend/app/notify/telegram.go
@@ -86,6 +86,10 @@ func NewTelegram(token string, channelID string, timeout time.Duration, api stri
// Send to telegram channel
func (t *Telegram) Send(ctx context.Context, req Request) error {
+ if req.Comment.ID == "" {
+ // verification request received, send nothing
+ return nil
+ }
client := http.Client{Timeout: telegramTimeOut}
log.Printf("[DEBUG] send telegram notification to %s, comment id %s", t.channelID, req.Comment.ID)
diff --git a/backend/app/notify/telegram_test.go b/backend/app/notify/telegram_test.go
index 2dd3fdd6..e10e096c 100644
--- a/backend/app/notify/telegram_test.go
+++ b/backend/app/notify/telegram_test.go
@@ -58,7 +58,7 @@ func TestTelegram_Send(t *testing.T) {
tb, err := NewTelegram("good-token", "remark_test", 2*time.Second, ts.URL+"/")
assert.NoError(t, err)
assert.NotNil(t, tb)
- c := store.Comment{Text: "some text", ParentID: "1"}
+ c := store.Comment{Text: "some text", ParentID: "1", ID: "999"}
c.User.Name = "from"
cp := store.Comment{Text: "some parent text"}
cp.User.Name = "to"
@@ -76,6 +76,7 @@ func TestTelegram_Send(t *testing.T) {
assert.Contains(t, err.Error(), "unexpected telegram status code 404", "send on broken tg")
assert.Equal(t, "telegram: @remark_test", tb.String())
+ require.Nil(t, tb.Send(context.TODO(), Request{}), "Empty Comment doesn't send anything")
}
func mockTelegramServer() *httptest.Server {