* Encode e-mail subject to support non-ASCII chars * Add test for email subject with unicode * Fix wrong test argument * Cleanup test function
432 lines
13 KiB
Go
432 lines
13 KiB
Go
package notify
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/tls"
|
|
"fmt"
|
|
"io"
|
|
"mime"
|
|
"mime/quotedprintable"
|
|
"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"
|
|
|
|
"github.com/umputun/remark42/backend/app/templates"
|
|
)
|
|
|
|
// EmailParams contain settings for email notifications
|
|
type EmailParams struct {
|
|
From string // from email address
|
|
AdminEmails []string // administrator emails to send copy of comment notification to
|
|
MsgTemplatePath string // path to request message template
|
|
VerificationSubject string // verification message sub
|
|
VerificationTemplatePath string // path to verification template
|
|
SubscribeURL string // full subscribe handler URL
|
|
UnsubscribeURL string // full unsubscribe handler URL
|
|
|
|
TokenGenFn func(userID, email, site string) (string, error) // Unsubscribe token generation function
|
|
}
|
|
|
|
// 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 {
|
|
UserName string
|
|
UserPicture string
|
|
CommentText string
|
|
CommentLink string
|
|
CommentDate time.Time
|
|
ParentUserName string
|
|
ParentUserPicture string
|
|
ParentCommentText string
|
|
ParentCommentLink string
|
|
ParentCommentDate time.Time
|
|
PostTitle string
|
|
Email string
|
|
UnsubscribeLink string
|
|
ForAdmin bool
|
|
}
|
|
|
|
// verifyTmplData store data for verification message template execution
|
|
type verifyTmplData struct {
|
|
User string
|
|
Token string
|
|
Email string
|
|
Site string
|
|
SubscribeURL string
|
|
}
|
|
|
|
const (
|
|
defaultVerificationSubject = "Email verification"
|
|
defaultEmailTimeout = 10 * time.Second
|
|
defaultEmailTemplatePath = "email_reply.html.tmpl"
|
|
defaultEmailVerificationTemplatePath = "email_confirmation_subscription.html.tmpl"
|
|
)
|
|
|
|
// NewEmail makes new Email object, returns error in case of e.MsgTemplate or e.VerificationTemplate parsing error
|
|
func NewEmail(emailParams EmailParams, smtpParams SMTPParams) (*Email, error) {
|
|
// set up Email emailParams
|
|
res := Email{EmailParams: emailParams}
|
|
res.smtp = &emailClient{}
|
|
res.SMTPParams = smtpParams
|
|
if res.TimeOut <= 0 {
|
|
res.TimeOut = defaultEmailTimeout
|
|
}
|
|
|
|
if res.VerificationSubject == "" {
|
|
res.VerificationSubject = defaultVerificationSubject
|
|
}
|
|
|
|
// initialize templates
|
|
err := res.setTemplates()
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "can't set templates")
|
|
}
|
|
|
|
log.Printf("[DEBUG] Create new email notifier for server %s with user %s, timeout=%s",
|
|
res.Host, res.Username, res.TimeOut)
|
|
|
|
return &res, nil
|
|
}
|
|
|
|
func (e *Email) setTemplates() error {
|
|
var err error
|
|
var msgTmplFile, verifyTmplFile []byte
|
|
fs := templates.NewFS()
|
|
|
|
if e.VerificationTemplatePath == "" {
|
|
e.VerificationTemplatePath = defaultEmailVerificationTemplatePath
|
|
}
|
|
|
|
if e.MsgTemplatePath == "" {
|
|
e.MsgTemplatePath = defaultEmailTemplatePath
|
|
}
|
|
|
|
if msgTmplFile, err = fs.ReadFile(e.MsgTemplatePath); err != nil {
|
|
return errors.Wrapf(err, "can't read message template")
|
|
}
|
|
if verifyTmplFile, err = fs.ReadFile(e.VerificationTemplatePath); err != nil {
|
|
return errors.Wrapf(err, "can't read verification template")
|
|
}
|
|
if e.msgTmpl, err = template.New("msgTmpl").Parse(string(msgTmplFile)); err != nil {
|
|
return errors.Wrapf(err, "can't parse message template")
|
|
}
|
|
if e.verifyTmpl, err = template.New("verifyTmpl").Parse(string(verifyTmplFile)); err != nil {
|
|
return errors.Wrapf(err, "can't parse verification template")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// Send email about comment reply to Request.Emails and Email.AdminEmails
|
|
// if they're set.
|
|
// Thread safe
|
|
func (e *Email) Send(ctx context.Context, req Request) error {
|
|
select {
|
|
case <-ctx.Done():
|
|
return errors.Errorf("sending email messages about comment %q aborted due to canceled context", req.Comment.ID)
|
|
default:
|
|
}
|
|
|
|
result := new(multierror.Error)
|
|
|
|
for _, email := range req.Emails {
|
|
err := e.buildAndSendMessage(ctx, req, email, false)
|
|
result = multierror.Append(errors.Wrapf(err, "problem sending user email notification to %q", email))
|
|
}
|
|
|
|
for _, email := range e.AdminEmails {
|
|
err := e.buildAndSendMessage(ctx, req, email, true)
|
|
result = multierror.Append(errors.Wrapf(err, "problem sending admin email notification to %q", email))
|
|
}
|
|
|
|
return result.ErrorOrNil()
|
|
}
|
|
|
|
func (e *Email) buildAndSendMessage(ctx context.Context, req Request, email string, forAdmin bool) error {
|
|
log.Printf("[DEBUG] send notification via %s, comment id %s", e, req.Comment.ID)
|
|
msg, err := e.buildMessageFromRequest(req, email, forAdmin)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.sendMessage(emailMessage{from: e.From, to: email, message: msg})
|
|
})
|
|
}
|
|
|
|
// SendVerification email verification VerificationRequest.Email if it's set.
|
|
// Thread safe
|
|
func (e *Email) SendVerification(ctx context.Context, req VerificationRequest) 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.User)
|
|
default:
|
|
}
|
|
|
|
log.Printf("[DEBUG] send verification via %s, user %s", e, req.User)
|
|
msg, err := e.buildVerificationMessage(req.User, req.Email, req.Token, req.SiteID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return repeater.NewDefault(5, time.Millisecond*250).Do(
|
|
ctx,
|
|
func() error {
|
|
return e.sendMessage(emailMessage{from: e.From, to: req.Email, message: msg})
|
|
})
|
|
}
|
|
|
|
// buildVerificationMessage generates verification email message based on given input
|
|
func (e *Email) buildVerificationMessage(user, email, token, site string) (string, error) {
|
|
subject := e.VerificationSubject
|
|
msg := bytes.Buffer{}
|
|
err := e.verifyTmpl.Execute(&msg, verifyTmplData{
|
|
User: user,
|
|
Token: token,
|
|
Email: email,
|
|
Site: site,
|
|
SubscribeURL: e.SubscribeURL,
|
|
})
|
|
if err != nil {
|
|
return "", errors.Wrapf(err, "error executing template to build verification message")
|
|
}
|
|
return e.buildMessage(subject, msg.String(), email, "text/html", "")
|
|
}
|
|
|
|
// buildMessageFromRequest generates email message based on Request using e.MsgTemplate
|
|
func (e *Email) buildMessageFromRequest(req Request, email string, 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 %q", req.Comment.PostTitle)
|
|
}
|
|
|
|
token, err := e.TokenGenFn(req.parent.User.ID, email, req.Comment.Locator.SiteID)
|
|
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 = ""
|
|
}
|
|
|
|
commentURLPrefix := req.Comment.Locator.URL + uiNav
|
|
msg := bytes.Buffer{}
|
|
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(), email, "text/html", unsubscribeLink)
|
|
}
|
|
|
|
// buildMessage generates email message to send using net/smtp.Data()
|
|
func (e *Email) buildMessage(subject, body, to, contentType, unsubscribeLink string) (message string, err error) {
|
|
addHeader := func(msg, h, v string) string {
|
|
msg += fmt.Sprintf("%s: %s\n", h, v)
|
|
return msg
|
|
}
|
|
message = addHeader(message, "From", e.From)
|
|
message = addHeader(message, "To", to)
|
|
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", subject))
|
|
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
|
|
|
|
if contentType != "" {
|
|
message = addHeader(message, "MIME-version", "1.0")
|
|
message = addHeader(message, "Content-Type", contentType+`; charset="UTF-8"`)
|
|
}
|
|
|
|
if unsubscribeLink != "" {
|
|
// https://support.google.com/mail/answer/81126 -> "Include option to unsubscribe"
|
|
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
|
|
message = addHeader(message, "List-Unsubscribe", "<"+unsubscribeLink+">")
|
|
}
|
|
|
|
message = addHeader(message, "Date", time.Now().Format(time.RFC1123Z))
|
|
|
|
buff := &bytes.Buffer{}
|
|
qp := quotedprintable.NewWriter(buff)
|
|
if _, err := qp.Write([]byte(body)); err != nil {
|
|
return "", err
|
|
}
|
|
// flush now, must NOT use defer, for small body, defer may cause buff.String() got empty body
|
|
if err := qp.Close(); err != nil {
|
|
return "", fmt.Errorf("quotedprintable Write failed: %w", err)
|
|
}
|
|
m := buff.String()
|
|
message += "\n" + m
|
|
return message, nil
|
|
}
|
|
|
|
// sendMessage sends messages to server in a new connection, closing the connection after finishing.
|
|
// Thread safe.
|
|
func (e *Email) sendMessage(m emailMessage) error {
|
|
if e.smtp == nil {
|
|
return errors.New("sendMessage called without client set")
|
|
}
|
|
client, err := e.smtp.Create(e.SMTPParams)
|
|
if err != nil {
|
|
return errors.Wrap(err, "failed to make smtp Create")
|
|
}
|
|
|
|
defer func() {
|
|
if err = client.Quit(); err != nil {
|
|
log.Printf("[WARN] failed to send quit command to %s:%d, %v", e.Host, e.Port, err)
|
|
if err = client.Close(); err != nil {
|
|
log.Printf("[WARN] can't close smtp connection, %v", err)
|
|
}
|
|
}
|
|
}()
|
|
|
|
if err = client.Mail(m.from); err != nil {
|
|
return errors.Wrapf(err, "bad from address %q", m.from)
|
|
}
|
|
if err = client.Rcpt(m.to); err != nil {
|
|
return errors.Wrapf(err, "bad to address %q", m.to)
|
|
}
|
|
|
|
writer, err := client.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
|
|
}
|
|
|
|
// String representation of Email object
|
|
func (e *Email) String() string {
|
|
return fmt.Sprintf("email: from %q with username '%s' at server %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) {
|
|
authenticate := func(c *smtp.Client) error {
|
|
if params.Username == "" || params.Password == "" {
|
|
return nil
|
|
}
|
|
auth := smtp.PlainAuth("", params.Username, params.Password, params.Host)
|
|
if err := c.Auth(auth); err != nil {
|
|
return errors.Wrapf(err, "failed to auth to smtp %s:%d", params.Host, params.Port)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
var c *smtp.Client
|
|
srvAddress := fmt.Sprintf("%s:%d", params.Host, params.Port)
|
|
if params.TLS {
|
|
tlsConf := &tls.Config{
|
|
InsecureSkipVerify: false,
|
|
ServerName: params.Host,
|
|
MinVersion: tls.VersionTLS12,
|
|
}
|
|
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, authenticate(c)
|
|
}
|
|
|
|
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, params.Host)
|
|
if err != nil {
|
|
return nil, errors.Wrap(err, "failed to dial")
|
|
}
|
|
|
|
return c, authenticate(c)
|
|
}
|