package mail import ( "bytes" "crypto/rand" "crypto/tls" "errors" "fmt" "io" "math" "math/big" "mime/quotedprintable" "net" "net/mail" "net/smtp" "net/textproto" "strconv" "strings" "time" "github.com/jmhodges/clock" "github.com/letsencrypt/boulder/core" blog "github.com/letsencrypt/boulder/log" "github.com/letsencrypt/boulder/metrics" ) 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 stats metrics.Scope reconnectBase time.Duration reconnectMax time.Duration } type dialer interface { Dial() (smtpClient, error) } type smtpClient interface { Mail(string) error Rcpt(string) error Data() (io.WriteCloser, 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.Debug(fmt.Sprintf("MAIL FROM:<%s>", from)) return nil } func (d dryRunClient) Rcpt(to string) error { d.log.Debug(fmt.Sprintf("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) { d.log.Debug(fmt.Sprintf("data: %s", string(p))) return len(p), nil } // New constructs a Mailer to represent an account on a particular mail // transfer agent. func New( server, port, username, password string, from mail.Address, logger blog.Logger, stats metrics.Scope, reconnectBase time.Duration, reconnectMax time.Duration) *MailerImpl { return &MailerImpl{ dialer: &dialerImpl{ username: username, password: password, server: server, port: port, }, log: logger, from: from, clk: clock.Default(), csprgSource: realSource{}, stats: stats.NewScope("Mailer"), reconnectBase: reconnectBase, reconnectMax: reconnectMax, } } // 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 { stats := metrics.NewNoopScope() return &MailerImpl{ dialer: dryRunClient{logger}, from: from, clk: clock.Default(), csprgSource: realSource{}, stats: stats, } } 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.Info(fmt.Sprintf("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.Warning(fmt.Sprintf("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 } func (di *dialerImpl) Dial() (smtpClient, error) { hostport := net.JoinHostPort(di.server, di.port) var conn net.Conn var err error // By convention, port 465 is TLS-wrapped SMTP, while 587 is plaintext SMTP // (with STARTTLS as best-effort). if di.port == "465" { conn, err = tls.Dial("tcp", hostport, nil) } else { conn, err = net.Dial("tcp", hostport) } 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 } 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 err } } w, err := m.client.Data() if err != nil { return err } _, err = w.Write(body) if err != nil { return err } err = w.Close() if err != nil { return err } return nil } // 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 { m.stats.Inc("SendMail.Attempts", 1) 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 { // If the error is an EOF, we should try to reconnect on a backoff // schedule, sleeping between attempts. m.stats.Inc("SendMail.Errors.EOF", 1) m.reconnect() // After reconnecting, loop around and try `sendOne` again. m.stats.Inc("SendMail.Reconnects", 1) continue } else if err != nil { /* * 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 */ if protoErr, ok := err.(*textproto.Error); ok && protoErr.Code == 421 { m.stats.Inc("SendMail.Errors.SMTP.421", 1) m.reconnect() m.stats.Inc("SendMail.Reconnects", 1) } else { // If it wasn't an EOF error or a SMTP 421 it is unexpected and we // return from SendMail() with an error m.stats.Inc("SendMail.Errors", 1) return err } } } m.stats.Inc("SendMail.Successes", 1) 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() }