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 is an interface that allows creating Conns. Implementations must // be safe for concurrent use. type Mailer interface { Connect() (Conn, error) } // Conn is an interface that allows sending mail. When you are done with a // Conn, call Close(). Implementations are not required to be safe for // concurrent use. type Conn interface { SendMail([]string, string, string) error Close() error } // connImpl represents a single connection to a mail server. It is not safe // for concurrent use. type connImpl struct { config client smtpClient } // mailerImpl defines a mail transfer agent to use for sending mail. It is // safe for concurrent us. type mailerImpl struct { config } type config struct { log blog.Logger dialer dialer from mail.Address 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{ config: config{ 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, }, } } // NewDryRun 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{ config: config{ 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 (c config) generateMessage(to []string, subject, body string) ([]byte, error) { mid := c.csprgSource.generate() now := c.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", c.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(), c.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 (c *connImpl) reconnect() { for i := 0; ; i++ { sleepDuration := core.RetryBackoff(i, c.reconnectBase, c.reconnectMax, 2) c.log.Infof("sleeping for %s before reconnecting mailer", sleepDuration) c.clk.Sleep(sleepDuration) c.log.Info("attempting to reconnect mailer") client, err := c.dialer.Dial() if err != nil { c.log.Warningf("reconnect error: %s", err) continue } c.client = client break } c.log.Info("reconnected successfully") } // Connect opens a connection to the specified mail server. It must be called // before SendMail. func (m *mailerImpl) Connect() (Conn, error) { client, err := m.dialer.Dial() if err != nil { return nil, err } return &connImpl{m.config, client}, 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 (c *connImpl) resetAndError(err error) error { if err == io.EOF { return err } if err2 := c.client.Reset(); err2 != nil { return fmt.Errorf("%s (also, on sending RSET: %s)", err, err2) } return err } func (c *connImpl) sendOne(to []string, subject, msg string) error { if c.client == nil { return errors.New("call Connect before SendMail") } body, err := c.generateMessage(to, subject, msg) if err != nil { return err } if err = c.client.Mail(c.from.String()); err != nil { return err } for _, t := range to { if err = c.client.Rcpt(t); err != nil { return c.resetAndError(err) } } w, err := c.client.Data() if err != nil { return c.resetAndError(err) } _, err = w.Write(body) if err != nil { return c.resetAndError(err) } err = w.Close() if err != nil { return c.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 501: true, // Bad recipient address syntax 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 (c *connImpl) SendMail(to []string, subject, msg string) error { var protoErr *textproto.Error for { err := c.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 { c.sendMailAttempts.WithLabelValues("failure", "EOF").Inc() // If the error is an EOF, we should try to reconnect on a backoff // schedule, sleeping between attempts. c.reconnect() // After reconnecting, loop around and try `sendOne` again. continue } else if errors.Is(err, syscall.ECONNRESET) { c.sendMailAttempts.WithLabelValues("failure", "TCP RST").Inc() // If the error is `syscall.ECONNRESET`, we should try to reconnect on a backoff // schedule, sleeping between attempts. c.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. c.sendMailAttempts.WithLabelValues("failure", "EPIPE").Inc() c.reconnect() continue } else if errors.As(err, &protoErr) && protoErr.Code == 421 { c.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 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 */ c.reconnect() // After reconnecting, loop around and try `sendOne` again. continue } else if errors.As(err, &protoErr) && badAddressErrorCodes[protoErr.Code] { c.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 c.sendMailAttempts.WithLabelValues("failure", "unexpected").Inc() return err } } c.sendMailAttempts.WithLabelValues("success", "").Inc() return nil } // Close closes the connection. func (c *connImpl) Close() error { err := c.client.Close() if err != nil { return err } c.client = nil return nil }