335 lines
7.9 KiB
Go
335 lines
7.9 KiB
Go
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-ASCIR 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 instace 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
|
|
*/
|
|
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()
|
|
}
|