Previously, few of them resulted in panics when called more than once.
367 lines
10 KiB
Go
367 lines
10 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
|
|
authMethod authMethod // auth method
|
|
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"
|
|
InReplyTo string // Identifier for email group (category), used for email grouping
|
|
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: "",
|
|
authMethod: authMethodPlain,
|
|
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 auth := em.auth(); auth != nil {
|
|
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.DialWithDialer(&net.Dialer{Timeout: em.timeOut}, "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
|
|
}
|
|
|
|
// auth returns an smtp.Auth that implements SMTP authentication mechanism
|
|
// depends on Sender settings.
|
|
func (em *Sender) auth() smtp.Auth {
|
|
if em.smtpUserName == "" || em.smtpPassword == "" {
|
|
return nil // no auth
|
|
}
|
|
|
|
if em.authMethod == authMethodLogin {
|
|
return newLoginAuth(em.smtpUserName, em.smtpPassword, em.host)
|
|
}
|
|
return smtp.PlainAuth("", em.smtpUserName, em.smtpPassword, em.host)
|
|
}
|
|
|
|
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+">")
|
|
}
|
|
|
|
if params.InReplyTo != "" {
|
|
message = addHeader(message, "In-reply-to", "<"+params.InReplyTo+">")
|
|
}
|
|
|
|
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{}) {}
|