boulder/mail/mailer.go

407 lines
11 KiB
Go

package mail
import (
"bytes"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"math"
"math/big"
"mime/quotedprintable"
"net"
"net/mail"
"net/smtp"
"net/textproto"
"strconv"
"strings"
"syscall"
"time"
"github.com/jmhodges/clock"
"github.com/prometheus/client_golang/prometheus"
"github.com/letsencrypt/boulder/core"
blog "github.com/letsencrypt/boulder/log"
)
type idGenerator interface {
generate() *big.Int
}
var maxBigInt = big.NewInt(math.MaxInt64)
type realSource struct{}
func (s realSource) generate() *big.Int {
randInt, err := rand.Int(rand.Reader, maxBigInt)
if err != nil {
panic(err)
}
return randInt
}
// Mailer provides the interface for a mailer
type Mailer interface {
SendMail([]string, string, string) error
Connect() error
Close() error
}
// MailerImpl defines a mail transfer agent to use for sending mail. It is not
// safe for concurrent access.
type MailerImpl struct {
log blog.Logger
dialer dialer
from mail.Address
client smtpClient
clk clock.Clock
csprgSource idGenerator
reconnectBase time.Duration
reconnectMax time.Duration
sendMailAttempts *prometheus.CounterVec
}
type dialer interface {
Dial() (smtpClient, error)
}
type smtpClient interface {
Mail(string) error
Rcpt(string) error
Data() (io.WriteCloser, error)
Reset() error
Close() error
}
type dryRunClient struct {
log blog.Logger
}
func (d dryRunClient) Dial() (smtpClient, error) {
return d, nil
}
func (d dryRunClient) Mail(from string) error {
d.log.Debugf("MAIL FROM:<%s>", from)
return nil
}
func (d dryRunClient) Rcpt(to string) error {
d.log.Debugf("RCPT TO:<%s>", to)
return nil
}
func (d dryRunClient) Close() error {
return nil
}
func (d dryRunClient) Data() (io.WriteCloser, error) {
return d, nil
}
func (d dryRunClient) Write(p []byte) (n int, err error) {
for _, line := range strings.Split(string(p), "\n") {
d.log.Debugf("data: %s", line)
}
return len(p), nil
}
func (d dryRunClient) Reset() (err error) {
d.log.Debugf("RESET")
return nil
}
// New constructs a Mailer to represent an account on a particular mail
// transfer agent.
func New(
server,
port,
username,
password string,
rootCAs *x509.CertPool,
from mail.Address,
logger blog.Logger,
stats prometheus.Registerer,
reconnectBase time.Duration,
reconnectMax time.Duration) *MailerImpl {
sendMailAttempts := prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "send_mail_attempts",
Help: "A counter of send mail attempts labelled by result",
}, []string{"result", "error"})
stats.MustRegister(sendMailAttempts)
return &MailerImpl{
dialer: &dialerImpl{
username: username,
password: password,
server: server,
port: port,
rootCAs: rootCAs,
},
log: logger,
from: from,
clk: clock.New(),
csprgSource: realSource{},
reconnectBase: reconnectBase,
reconnectMax: reconnectMax,
sendMailAttempts: sendMailAttempts,
}
}
// New constructs a Mailer suitable for doing a dry run. It simply logs each
// command that would have been run, at debug level.
func NewDryRun(from mail.Address, logger blog.Logger) *MailerImpl {
return &MailerImpl{
dialer: dryRunClient{logger},
from: from,
clk: clock.New(),
csprgSource: realSource{},
sendMailAttempts: prometheus.NewCounterVec(prometheus.CounterOpts{
Name: "send_mail_attempts",
Help: "A counter of send mail attempts labelled by result",
}, []string{"result", "error"}),
}
}
func (m *MailerImpl) generateMessage(to []string, subject, body string) ([]byte, error) {
mid := m.csprgSource.generate()
now := m.clk.Now().UTC()
addrs := []string{}
for _, a := range to {
if !core.IsASCII(a) {
return nil, fmt.Errorf("Non-ASCII email address")
}
addrs = append(addrs, strconv.Quote(a))
}
headers := []string{
fmt.Sprintf("To: %s", strings.Join(addrs, ", ")),
fmt.Sprintf("From: %s", m.from.String()),
fmt.Sprintf("Subject: %s", subject),
fmt.Sprintf("Date: %s", now.Format(time.RFC822)),
fmt.Sprintf("Message-Id: <%s.%s.%s>", now.Format("20060102T150405"), mid.String(), m.from.Address),
"MIME-Version: 1.0",
"Content-Type: text/plain; charset=UTF-8",
"Content-Transfer-Encoding: quoted-printable",
}
for i := range headers[1:] {
// strip LFs
headers[i] = strings.Replace(headers[i], "\n", "", -1)
}
bodyBuf := new(bytes.Buffer)
mimeWriter := quotedprintable.NewWriter(bodyBuf)
_, err := mimeWriter.Write([]byte(body))
if err != nil {
return nil, err
}
err = mimeWriter.Close()
if err != nil {
return nil, err
}
return []byte(fmt.Sprintf(
"%s\r\n\r\n%s\r\n",
strings.Join(headers, "\r\n"),
bodyBuf.String(),
)), nil
}
func (m *MailerImpl) reconnect() {
for i := 0; ; i++ {
sleepDuration := core.RetryBackoff(i, m.reconnectBase, m.reconnectMax, 2)
m.log.Infof("sleeping for %s before reconnecting mailer", sleepDuration)
m.clk.Sleep(sleepDuration)
m.log.Info("attempting to reconnect mailer")
err := m.Connect()
if err != nil {
m.log.Warningf("reconnect error: %s", err)
continue
}
break
}
m.log.Info("reconnected successfully")
}
// Connect opens a connection to the specified mail server. It must be called
// before SendMail.
func (m *MailerImpl) Connect() error {
client, err := m.dialer.Dial()
if err != nil {
return err
}
m.client = client
return nil
}
type dialerImpl struct {
username, password, server, port string
rootCAs *x509.CertPool
}
func (di *dialerImpl) Dial() (smtpClient, error) {
hostport := net.JoinHostPort(di.server, di.port)
var conn net.Conn
var err error
conn, err = tls.Dial("tcp", hostport, &tls.Config{
RootCAs: di.rootCAs,
})
if err != nil {
return nil, err
}
client, err := smtp.NewClient(conn, di.server)
if err != nil {
return nil, err
}
auth := smtp.PlainAuth("", di.username, di.password, di.server)
if err = client.Auth(auth); err != nil {
return nil, err
}
return client, nil
}
// resetAndError resets the current mail transaction and then returns its
// argument as an error. If the reset command also errors, it combines both
// errors and returns them. Without this we would get `nested MAIL command`.
// https://github.com/letsencrypt/boulder/issues/3191
func (m *MailerImpl) resetAndError(err error) error {
if err == io.EOF {
return err
}
if err2 := m.client.Reset(); err2 != nil {
return fmt.Errorf("%s (also, on sending RSET: %s)", err, err2)
}
return err
}
func (m *MailerImpl) sendOne(to []string, subject, msg string) error {
if m.client == nil {
return errors.New("call Connect before SendMail")
}
body, err := m.generateMessage(to, subject, msg)
if err != nil {
return err
}
if err = m.client.Mail(m.from.String()); err != nil {
return err
}
for _, t := range to {
if err = m.client.Rcpt(t); err != nil {
return m.resetAndError(err)
}
}
w, err := m.client.Data()
if err != nil {
return m.resetAndError(err)
}
_, err = w.Write(body)
if err != nil {
return m.resetAndError(err)
}
err = w.Close()
if err != nil {
return m.resetAndError(err)
}
return nil
}
// BadAddressSMTPError is returned by SendMail when the server rejects a message
// but for a reason that doesn't prevent us from continuing to send mail. The
// error message contains the error code and the error message returned from the
// server.
type BadAddressSMTPError struct {
Message string
}
func (e BadAddressSMTPError) Error() string {
return e.Message
}
// Based on reading of various SMTP documents these are a handful
// of errors we are likely to be able to continue sending mail after
// receiving. The majority of these errors boil down to 'bad address'.
var badAddressErrorCodes = map[int]bool{
401: true, // Invalid recipient
422: true, // Recipient mailbox is full
441: true, // Recipient server is not responding
450: true, // User's mailbox is not available
510: true, // Invalid recipient
511: true, // Invalid recipient
513: true, // Address type invalid
541: true, // Recipient rejected message
550: true, // Non-existent address
553: true, // Non-existent address
}
// SendMail sends an email to the provided list of recipients. The email body
// is simple text.
func (m *MailerImpl) SendMail(to []string, subject, msg string) error {
var protoErr *textproto.Error
for {
err := m.sendOne(to, subject, msg)
if err == nil {
// If the error is nil, we sent the mail without issue. nice!
break
} else if err == io.EOF {
m.sendMailAttempts.WithLabelValues("failure", "EOF").Inc()
// If the error is an EOF, we should try to reconnect on a backoff
// schedule, sleeping between attempts.
m.reconnect()
// After reconnecting, loop around and try `sendOne` again.
continue
} else if errors.Is(err, syscall.ECONNRESET) {
m.sendMailAttempts.WithLabelValues("failure", "TCP RST").Inc()
// If the error is `syscall.ECONNRESET`, we should try to reconnect on a backoff
// schedule, sleeping between attempts.
m.reconnect()
// After reconnecting, loop around and try `sendOne` again.
continue
} else if errors.Is(err, syscall.EPIPE) {
// EPIPE also seems to be a common way to signal TCP RST.
m.sendMailAttempts.WithLabelValues("failure", "EPIPE").Inc()
m.reconnect()
continue
} else if errors.As(err, &protoErr) && protoErr.Code == 421 {
m.sendMailAttempts.WithLabelValues("failure", "SMTP 421").Inc()
/*
* If the error is an instance of `textproto.Error` with a SMTP error code,
* and that error code is 421 then treat this as a reconnect-able event.
*
* The SMTP RFC defines this error code as:
* 421 <domain> Service not available, closing transmission channel
* (This may be a reply to any command if the service knows it
* must shut down)
*
* In practice we see this code being used by our production SMTP server
* when the connection has gone idle for too long. For more information
* see issue #2249[0].
*
* [0] - https://github.com/letsencrypt/boulder/issues/2249
*/
m.reconnect()
// After reconnecting, loop around and try `sendOne` again.
continue
} else if errors.As(err, &protoErr) && badAddressErrorCodes[protoErr.Code] {
m.sendMailAttempts.WithLabelValues("failure", fmt.Sprintf("SMTP %d", protoErr.Code)).Inc()
return BadAddressSMTPError{fmt.Sprintf("%d: %s", protoErr.Code, protoErr.Msg)}
} else {
// If it wasn't an EOF error or a recoverable SMTP error it is unexpected and we
// return from SendMail() with the error
m.sendMailAttempts.WithLabelValues("failure", "unexpected").Inc()
return err
}
}
m.sendMailAttempts.WithLabelValues("success", "").Inc()
return nil
}
// Close closes the connection.
func (m *MailerImpl) Close() error {
if m.client == nil {
return errors.New("call Connect before Close")
}
return m.client.Close()
}