Email sender implementation (#471)
* email sender initial implementation * add VerificationMetadata into notify.Request * multiple review fixes - customizable verification notification message subject - clarify autoFlush nature in its commentary - defer writer.Close in Email.sendEmail instead of calling it manually, potentially missing the call if error happened between the creation and closing. * add explanatory commentary to notify.Request structure * fix TCP connection timout commentary typo * improve table tests presence * introduce parallelism to tests * abstract smtpClientWithMaker away * fix incorrect Email reference in Email.sendMessages * naming fixes, remove t.Parallel() from tests * consistent space in commentary * rename sendEmail to smtpSend, rearrange variables definitions * switch Email to create new connection for every Send request * fix tests for connection-per-submit email sending * fix tests * simplify sender object
This commit is contained in:
committed by
Umputun
parent
ea8ac08c72
commit
b40cb7866b
@@ -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}}
|
||||
|
||||
↦ <a href="{{.Link}}">{{if .PostTitle}}{{.PostTitle}}{{else}}original comment{{end}}</a>
|
||||
`
|
||||
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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
↦ <a href="#remark42__comment-999">test_title</a>
|
||||
`
|
||||
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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user