Files
remark42/backend/vendor/github.com/go-pkgz/email/email.go
T
Dmitry VerkhoturovandUmputun 3b5f44da46 bump go modules, fix StartTLS email notifications
In #1359, we discovered that StartTLS was not working\
due to the wrong host passed. This bumps the library for the fix.

Also, after a switch to go-pkgz/notify MailGun email sending
broke due to the difference in the destination email parsing,
the fix is also applied after this commit.
2022-05-20 16:00:30 -05:00

348 lines
9.7 KiB
Go

// Package email provides email sender
package email
import (
"bytes"
"crypto/tls"
"encoding/base64"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"mime/quotedprintable"
"net"
"net/http"
"net/smtp"
"net/textproto"
"os"
"path/filepath"
"strings"
"time"
)
//go:generate moq -out mocks/smpt_client.go -pkg mocks -skip-ensure -fmt goimports . SMTPClient
//go:generate moq -out mocks/logger.go -pkg mocks -skip-ensure -fmt goimports . Logger
// Sender implements email sender
type Sender struct {
smtpClient SMTPClient
logger Logger
host string // SMTP host
port int // SMTP port
contentType string // Content type, optional. Will trigger MIME and Content-Type headers
tls bool // TLS auth
starttls bool // StartTLS
smtpUserName string // username
smtpPassword string // password
timeOut time.Duration
contentCharset string
timeNow func() time.Time
}
// Params contains all user-defined parameters to send emails
type Params struct {
From string // From email field
To []string // From email field
Subject string // Email subject
UnsubscribeLink string // POST, https://support.google.com/mail/answer/81126 -> "Use one-click unsubscribe"
Attachments []string // Attachments path
InlineImages []string // InlineImages images path
}
// Logger is used to log errors and debug messages
type Logger interface {
Logf(format string, args ...interface{})
}
// SMTPClient interface defines subset of net/smtp used by email client
type SMTPClient interface {
Mail(from string) error
Auth(auth smtp.Auth) error
Rcpt(to string) error
Data() (io.WriteCloser, error)
Quit() error
Close() error
}
// NewSender creates email client with prepared smtp
func NewSender(smtpHost string, options ...Option) *Sender {
res := Sender{
smtpClient: nil,
logger: nopLogger{},
host: smtpHost,
port: 25,
contentType: `text/plain`,
tls: false,
smtpUserName: "",
smtpPassword: "",
contentCharset: "UTF-8",
timeOut: time.Second * 30,
timeNow: time.Now,
}
for _, opt := range options {
opt(&res)
}
res.logger.Logf("[INFO] new email sender created with host: %s:%d, tls: %v, username: %q, timeout: %v, "+
"content type: %q, charset: %q", smtpHost,
res.port, res.tls, res.smtpUserName, res.timeOut, res.contentType, res.contentCharset)
return &res
}
// Send email with given text
// If SMTPClient defined in Email struct it will be used, if not - new smtp.Client on each send.
// Always closes client on completion or failure.
func (em *Sender) Send(text string, params Params) error {
em.logger.Logf("[DEBUG] send %q to %v", text, params.To)
client := em.smtpClient
if client == nil { // if client not set make new net/smtp
c, err := em.client()
if err != nil {
return fmt.Errorf("failed to make smtp client: %w", err)
}
client = c
}
var quit bool
defer func() {
if quit || client == nil { // quit set if Quit() call passed because it's closing connection as well.
return
}
if err := client.Close(); err != nil {
em.logger.Logf("[WARN] can't close smtp connection, %v", err)
}
}()
if len(params.To) == 0 {
return errors.New("no recipients")
}
if em.smtpUserName != "" && em.smtpPassword != "" {
auth := smtp.PlainAuth("", em.smtpUserName, em.smtpPassword, em.host)
if err := client.Auth(auth); err != nil {
return fmt.Errorf("failed to auth to smtp %s:%d, %w", em.host, em.port, err)
}
}
if err := client.Mail(params.From); err != nil {
return fmt.Errorf("bad from address %q: %w", params.From, err)
}
for _, rcpt := range params.To {
if err := client.Rcpt(rcpt); err != nil {
return fmt.Errorf("bad to address %q: %w", params.To, err)
}
}
writer, err := client.Data()
if err != nil {
return fmt.Errorf("can't make email writer: %w", err)
}
msg, err := em.buildMessage(text, params)
if err != nil {
return fmt.Errorf("can't make email message: %w", err)
}
buf := bytes.NewBufferString(msg)
if _, err = buf.WriteTo(writer); err != nil {
return fmt.Errorf("failed to send email body to %q: %w", params.To, err)
}
if err = writer.Close(); err != nil {
em.logger.Logf("[WARN] can't close smtp body writer, %v", err)
}
if err = client.Quit(); err != nil {
em.logger.Logf("[WARN] failed to send quit command to %s:%d, %v", em.host, em.port, err)
} else {
quit = true
}
return nil
}
func (em *Sender) String() string {
return fmt.Sprintf("smtp://%s:%d, auth:%v, tls:%v, starttls:%v, timeout:%v, content-type:%q, charset:%q",
em.host, em.port, em.smtpUserName != "", em.tls, em.starttls, em.timeOut, em.contentType, em.contentCharset)
}
func (em *Sender) client() (c *smtp.Client, err error) {
srvAddress := fmt.Sprintf("%s:%d", em.host, em.port)
tlsConf := &tls.Config{
InsecureSkipVerify: false,
ServerName: em.host,
MinVersion: tls.VersionTLS12,
}
if em.tls {
conn, e := tls.Dial("tcp", srvAddress, tlsConf)
if e != nil {
return nil, fmt.Errorf("failed to dial smtp tls to %s: %w", srvAddress, e)
}
if c, err = smtp.NewClient(conn, em.host); err != nil {
return nil, fmt.Errorf("failed to make smtp client for %s: %w", srvAddress, err)
}
return c, nil
}
conn, err := net.DialTimeout("tcp", srvAddress, em.timeOut)
if err != nil {
return nil, fmt.Errorf("timeout connecting to %s: %w", srvAddress, err)
}
c, err = smtp.NewClient(conn, em.host)
if err != nil {
return nil, fmt.Errorf("failed to dial: %w", err)
}
if em.starttls {
if err = c.StartTLS(tlsConf); err != nil {
return nil, fmt.Errorf("failed to start tls: %w", err)
}
}
return c, nil
}
func (em *Sender) buildMessage(text string, params Params) (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", params.From)
message = addHeader(message, "To", strings.Join(params.To, ","))
message = addHeader(message, "Subject", mime.BEncoding.Encode("utf-8", params.Subject))
if params.UnsubscribeLink != "" {
message = addHeader(message, "List-Unsubscribe-Post", "List-Unsubscribe=One-Click")
message = addHeader(message, "List-Unsubscribe", "<"+params.UnsubscribeLink+">")
}
withAttachments := len(params.Attachments) > 0
withInlineImg := len(params.InlineImages) > 0
if em.contentType != "" || withAttachments || withInlineImg {
message = addHeader(message, "MIME-version", "1.0")
}
message = addHeader(message, "Date", em.timeNow().Format(time.RFC1123Z))
buff := &bytes.Buffer{}
qp := quotedprintable.NewWriter(buff)
mpMixed := multipart.NewWriter(buff)
boundaryMixed := mpMixed.Boundary()
mpRelated := multipart.NewWriter(buff)
boundaryRelated := mpRelated.Boundary()
if withAttachments {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/mixed; boundary=%q\r\n\r\n%s\r",
boundaryMixed, "--"+boundaryMixed))
}
if withInlineImg {
message = addHeader(message, "Content-Type", fmt.Sprintf("multipart/related; boundary=%q\r\n\r\n%s\r",
boundaryRelated, "--"+boundaryRelated))
}
if em.contentType != "" {
message = addHeader(message, "Content-Transfer-Encoding", "quoted-printable")
message = addHeader(message, "Content-Type", fmt.Sprintf("%s; charset=%q", em.contentType, em.contentCharset))
}
if err := em.writeBody(qp, text); err != nil {
return "", fmt.Errorf("failed to write body: %w", err)
}
if withInlineImg {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpRelated, params.InlineImages, "inline"); err != nil {
return "", fmt.Errorf("failed to write inline images: %w", err)
}
}
if withAttachments {
buff.WriteString("\r\n\r\n")
if err := em.writeFiles(mpMixed, params.Attachments, "attachment"); err != nil {
return "", fmt.Errorf("failed to write attachments: %w", err)
}
}
m := buff.String()
message += "\n" + m
// returns base part of the file location
return message, nil
}
func (em *Sender) writeBody(wc io.WriteCloser, text string) error {
if _, err := wc.Write([]byte(text)); err != nil {
return err
}
if err := wc.Close(); err != nil {
return err
}
return nil
}
func (em *Sender) writeFiles(mp *multipart.Writer, files []string, disposition string) error {
for _, attachment := range files {
file, err := os.Open(filepath.Clean(attachment))
if err != nil {
return err
}
// we need first 512 bytes to detect file type
fTypeBuff := make([]byte, 512)
_, err = file.Read(fTypeBuff)
if err != nil {
return fmt.Errorf("failed to read file type %q: %w", attachment, err)
}
// remove null bytes in case file less than 512 bytes
fTypeBuff = bytes.Trim(fTypeBuff, "\x00")
fName := filepath.Base(attachment)
header := textproto.MIMEHeader{}
header.Set("Content-Type", http.DetectContentType(fTypeBuff)+"; name=\""+fName+"\"")
header.Set("Content-Transfer-Encoding", "base64")
switch disposition {
case "attachment":
header.Set("Content-Disposition", fmt.Sprintf("attachment; filename=%q", fName))
case "inline":
header.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fName))
header.Set("Content-ID", fmt.Sprintf("<%s>", fName))
}
writer, err := mp.CreatePart(header)
if err != nil {
return err
}
// set reader offset at the beginning of the file because we read first 512 bytes
_, err = file.Seek(0, io.SeekStart)
if err != nil {
return err
}
encoder := base64.NewEncoder(base64.StdEncoding, writer)
if _, err := io.Copy(encoder, file); err != nil {
return err
}
if err := encoder.Close(); err != nil {
return err
}
if err := file.Close(); err != nil {
return err
}
}
if err := mp.Close(); err != nil {
return err
}
return nil
}
type nopLogger struct{}
func (nopLogger) Logf(format string, args ...interface{}) {}