mox/smtpclient/client.go
Mechiel Lukkien d1b87cdb0d
replace packages slog and slices from golang.org/x/exp with stdlib
since we are now at go1.21 as minimum.
2024-02-08 14:49:01 +01:00

1226 lines
43 KiB
Go

// Package smtpclient is an SMTP client, for submitting to an SMTP server or
// delivering from a queue.
//
// Email clients can submit a message to SMTP server, after which the server is
// responsible for delivery to the final destination. A submission client
// typically connects with TLS, and PKIX-verifies the server's certificate. The
// client then authenticates using a SASL mechanism.
//
// Email servers manage a message queue, from which they will try to deliver
// messages. In case of temporary failures, the message is kept in the queue and
// tried again later. For delivery, no authentication is done. TLS is opportunistic
// by default (TLS certificates not verified), but TLS and certificate verification
// can be opted into by domains by specifying an MTA-STS policy for the domain, or
// DANE TLSA records for their MX hosts.
//
// Delivering a message from a queue would involve:
// 1. Looking up an MTA-STS policy, through a cache.
// 2. Resolving the MX targets for a domain, through smtpclient.GatherDestinations,
// and for each destination try delivery through:
// 3. Looking up IP addresses for the destination, with smtpclient.GatherIPs.
// 4. Looking up TLSA records for DANE, in case of authentic DNS responses
// (DNSSEC), with smtpclient.GatherTLSA.
// 5. Dialing the MX target with smtpclient.Dial.
// 6. Initializing a SMTP session with smtpclient.New, with proper TLS
// configuration based on discovered MTA-STS and DANE policies, and finally calling
// client.Deliver.
package smtpclient
import (
"bufio"
"context"
"crypto/tls"
"crypto/x509"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"net"
"reflect"
"strconv"
"strings"
"time"
"github.com/mjl-/adns"
"github.com/mjl-/mox/dane"
"github.com/mjl-/mox/dns"
"github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/moxio"
"github.com/mjl-/mox/sasl"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/stub"
"github.com/mjl-/mox/tlsrpt"
)
// todo future: add function to deliver message to multiple recipients. requires more elaborate return value, indicating success per message: some recipients may succeed, others may fail, and we should still deliver. to prevent backscatter, we also sometimes don't allow multiple recipients. ../rfc/5321:1144
var (
MetricCommands stub.HistogramVec = stub.HistogramVecIgnore{}
MetricTLSRequiredNoIgnored stub.CounterVec = stub.CounterVecIgnore{}
MetricPanicInc = func() {}
)
var (
ErrSize = errors.New("message too large for remote smtp server") // SMTP server announced a maximum message size and the message to be delivered exceeds it.
Err8bitmimeUnsupported = errors.New("remote smtp server does not implement 8bitmime extension, required by message")
ErrSMTPUTF8Unsupported = errors.New("remote smtp server does not implement smtputf8 extension, required by message")
ErrRequireTLSUnsupported = errors.New("remote smtp server does not implement requiretls extension, required for delivery")
ErrStatus = errors.New("remote smtp server sent unexpected response status code") // Relatively common, e.g. when a 250 OK was expected and server sent 451 temporary error.
ErrProtocol = errors.New("smtp protocol error") // After a malformed SMTP response or inconsistent multi-line response.
ErrTLS = errors.New("tls error") // E.g. handshake failure, or hostname verification was required and failed.
ErrBotched = errors.New("smtp connection is botched") // Set on a client, and returned for new operations, after an i/o error or malformed SMTP response.
ErrClosed = errors.New("client is closed")
)
// TLSMode indicates if TLS must, should or must not be used.
type TLSMode string
const (
// TLS immediately ("implicit TLS"), directly starting TLS on the TCP connection,
// so not using STARTTLS. Whether PKIX and/or DANE is verified is specified
// separately.
TLSImmediate TLSMode = "immediate"
// Required TLS with STARTTLS for SMTP servers. The STARTTLS command is always
// executed, even if the server does not announce support.
// Whether PKIX and/or DANE is verified is specified separately.
TLSRequiredStartTLS TLSMode = "requiredstarttls"
// Use TLS with STARTTLS if remote claims to support it.
TLSOpportunistic TLSMode = "opportunistic"
// TLS must not be attempted, e.g. due to earlier TLS handshake error.
TLSSkip TLSMode = "skip"
)
// Client is an SMTP client that can deliver messages to a mail server.
//
// Use New to make a new client.
type Client struct {
// OrigConn is the original (TCP) connection. We'll read from/write to conn, which
// can be wrapped in a tls.Client. We close origConn instead of conn because
// closing the TLS connection would send a TLS close notification, which may block
// for 5s if the server isn't reading it (because it is also sending it).
origConn net.Conn
conn net.Conn
tlsVerifyPKIX bool
ignoreTLSVerifyErrors bool
rootCAs *x509.CertPool
remoteHostname dns.Domain // TLS with SNI and name verification.
daneRecords []adns.TLSA // For authenticating (START)TLS connection.
daneMoreHostnames []dns.Domain // Additional allowed names in TLS certificate for DANE-TA.
daneVerifiedRecord *adns.TLSA // If non-nil, then will be set to verified DANE record if any.
// TLS connection success/failure are added. These are always non-nil, regardless
// of what was passed in opts. It lets us unconditionally dereference them.
recipientDomainResult *tlsrpt.Result // Either "sts" or "no-policy-found".
hostResult *tlsrpt.Result // Either "dane" or "no-policy-found".
r *bufio.Reader
w *bufio.Writer
tr *moxio.TraceReader // Kept for changing trace levels between cmd/auth/data.
tw *moxio.TraceWriter
log mlog.Log
lastlog time.Time // For adding delta timestamps between log lines.
cmds []string // Last or active command, for generating errors and metrics.
cmdStart time.Time // Start of command.
tls bool // Whether connection is TLS protected.
firstReadAfterHandshake bool // To detect TLS alert error from remote just after handshake.
botched bool // If set, protocol is out of sync and no further commands can be sent.
needRset bool // If set, a new delivery requires an RSET command.
remoteHelo string // From 220 greeting line.
extEcodes bool // Remote server supports sending extended error codes.
extStartTLS bool // Remote server supports STARTTLS.
ext8bitmime bool
extSize bool // Remote server supports SIZE parameter.
maxSize int64 // Max size of email message.
extPipelining bool // Remote server supports command pipelining.
extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension.
extAuthMechanisms []string // Supported authentication mechanisms.
extRequireTLS bool // Remote supports REQUIRETLS extension.
}
// Error represents a failure to deliver a message.
//
// Code, Secode, Command and Line are only set for SMTP-level errors, and are zero
// values otherwise.
type Error struct {
// Whether failure is permanent, typically because of 5xx response.
Permanent bool
// SMTP response status, e.g. 2xx for success, 4xx for transient error and 5xx for
// permanent failure.
Code int
// Short enhanced status, minus first digit and dot. Can be empty, e.g. for io
// errors or if remote does not send enhanced status codes. If remote responds with
// "550 5.7.1 ...", the Secode will be "7.1".
Secode string
// SMTP command causing failure.
Command string
// For errors due to SMTP responses, the full SMTP line excluding CRLF that caused
// the error. Typically the last line read.
Line string
// Underlying error, e.g. one of the Err variables in this package, or io errors.
Err error
}
// Unwrap returns the underlying Err.
func (e Error) Unwrap() error {
return e.Err
}
// Error returns a readable error string.
func (e Error) Error() string {
s := ""
if e.Err != nil {
s = e.Err.Error() + ", "
}
if e.Permanent {
s += "permanent"
} else {
s += "transient"
}
if e.Line != "" {
s += ": " + e.Line
}
return s
}
// Opts influence behaviour of Client.
type Opts struct {
// If auth is non-nil, authentication will be done with the returned sasl client.
// The function should select the preferred mechanism. Mechanisms are in upper
// case.
//
// The TLS connection state can be used for the SCRAM PLUS mechanisms, binding the
// authentication exchange to a TLS connection. It is only present for TLS
// connections.
//
// If no mechanism is supported, a nil client and nil error can be returned, and
// the connection will fail.
Auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)
DANERecords []adns.TLSA // If not nil, DANE records to verify.
DANEMoreHostnames []dns.Domain // For use with DANE, where additional certificate host names are allowed.
DANEVerifiedRecord *adns.TLSA // If non-empty, set to the DANE record that verified the TLS connection.
// If set, TLS verification errors (for DANE or PKIX) are ignored. Useful for
// delivering messages with message header "TLS-Required: No".
// Certificates are still verified, and results are still tracked for TLS
// reporting, but the connections will continue.
IgnoreTLSVerifyErrors bool
// If not nil, used instead of the system default roots for TLS PKIX verification.
RootCAs *x509.CertPool
// TLS verification successes/failures is added to these TLS reporting results.
// Once the STARTTLS handshake is attempted, a successful/failed connection is
// tracked.
RecipientDomainResult *tlsrpt.Result // MTA-STS or no policy.
HostResult *tlsrpt.Result // DANE or no policy.
}
// New initializes an SMTP session on the given connection, returning a client that
// can be used to deliver messages.
//
// New optionally starts TLS (for submission), reads the server greeting,
// identifies itself with a HELO or EHLO command, initializes TLS with STARTTLS if
// remote supports it and optionally authenticates. If successful, a client is
// returned on which eventually Close must be called. Otherwise an error is
// returned and the caller is responsible for closing the connection.
//
// Connecting to the correct host for delivery can be done using the Gather
// functions, and with Dial. The queue managing outgoing messages typically decides
// which host to deliver to, taking multiple MX records with preferences, other DNS
// records, MTA-STS, retries and special cases into account.
//
// tlsMode indicates if and how TLS may/must (not) be used.
//
// tlsVerifyPKIX indicates if TLS certificates must be validated against the
// PKIX/WebPKI certificate authorities (if TLS is done).
//
// DANE-verification is done when opts.DANERecords is not nil.
//
// TLS verification errors will be ignored if opts.IgnoreTLSVerification is set.
//
// If TLS is done, PKIX verification is always performed for tracking the results
// for TLS reporting, but if tlsVerifyPKIX is false, the verification result does
// not affect the connection.
//
// At the time of writing, delivery of email on the internet is done with
// opportunistic TLS without PKIX verification by default. Recipient domains can
// opt-in to PKIX verification by publishing an MTA-STS policy, or opt-in to DANE
// verification by publishing DNSSEC-protected TLSA records in DNS.
func New(ctx context.Context, elog *slog.Logger, conn net.Conn, tlsMode TLSMode, tlsVerifyPKIX bool, ehloHostname, remoteHostname dns.Domain, opts Opts) (*Client, error) {
ensureResult := func(r *tlsrpt.Result) *tlsrpt.Result {
if r == nil {
return &tlsrpt.Result{}
}
return r
}
c := &Client{
origConn: conn,
tlsVerifyPKIX: tlsVerifyPKIX,
ignoreTLSVerifyErrors: opts.IgnoreTLSVerifyErrors,
rootCAs: opts.RootCAs,
remoteHostname: remoteHostname,
daneRecords: opts.DANERecords,
daneMoreHostnames: opts.DANEMoreHostnames,
daneVerifiedRecord: opts.DANEVerifiedRecord,
lastlog: time.Now(),
cmds: []string{"(none)"},
recipientDomainResult: ensureResult(opts.RecipientDomainResult),
hostResult: ensureResult(opts.HostResult),
}
c.log = mlog.New("smtpclient", elog).WithFunc(func() []slog.Attr {
now := time.Now()
l := []slog.Attr{
slog.Duration("delta", now.Sub(c.lastlog)),
}
c.lastlog = now
return l
})
if tlsMode == TLSImmediate {
config := c.tlsConfig()
tlsconn := tls.Client(conn, config)
// The tlsrpt tracking isn't used by caller, but won't hurt.
if err := tlsconn.HandshakeContext(ctx); err != nil {
c.tlsResultAdd(0, 1, err)
return nil, err
}
c.firstReadAfterHandshake = true
c.tlsResultAdd(1, 0, nil)
c.conn = tlsconn
tlsversion, ciphersuite := moxio.TLSInfo(tlsconn)
c.log.Debug("tls client handshake done",
slog.String("tls", tlsversion),
slog.String("ciphersuite", ciphersuite),
slog.Any("servername", remoteHostname))
c.tls = true
} else {
c.conn = conn
}
// We don't wrap reads in a timeoutReader for fear of an optional TLS wrapper doing
// reads without the client asking for it. Such reads could result in a timeout
// error.
c.tr = moxio.NewTraceReader(c.log, "RS: ", c.conn)
c.r = bufio.NewReader(c.tr)
// We use a single write timeout of 30 seconds.
// todo future: use different timeouts ../rfc/5321:3610
c.tw = moxio.NewTraceWriter(c.log, "LC: ", timeoutWriter{c.conn, 30 * time.Second, c.log})
c.w = bufio.NewWriter(c.tw)
if err := c.hello(ctx, tlsMode, ehloHostname, opts.Auth); err != nil {
return nil, err
}
return c, nil
}
// reportedError wraps an error while indicating it was already tracked for TLS
// reporting.
type reportedError struct{ err error }
func (e reportedError) Error() string {
return e.err.Error()
}
func (e reportedError) Unwrap() error {
return e.err
}
func (c *Client) tlsConfig() *tls.Config {
// We always manage verification ourselves: We need to report in detail about
// failures. And we may have to verify both PKIX and DANE, record errors for
// each, and possibly ignore the errors.
verifyConnection := func(cs tls.ConnectionState) error {
// Collect verification errors. If there are none at the end, TLS validation
// succeeded. We may find validation problems below, record them for a TLS report
// but continue due to policies. We track the TLS reporting result in this
// function, wrapping errors in a reportedError.
var daneErr, pkixErr error
// DANE verification.
// daneRecords can be non-nil and empty, that's intended.
if c.daneRecords != nil {
verified, record, err := dane.Verify(c.log.Logger, c.daneRecords, cs, c.remoteHostname, c.daneMoreHostnames, c.rootCAs)
c.log.Debugx("dane verification", err, slog.Bool("verified", verified), slog.Any("record", record))
if verified {
if c.daneVerifiedRecord != nil {
*c.daneVerifiedRecord = record
}
} else {
// Track error for reports.
// todo spec: may want to propose adding a result for no-dane-match. dane allows multiple records, some mismatching/failing isn't fatal and reporting on each record is probably not productive. ../rfc/8460:541
fd := c.tlsrptFailureDetails(tlsrpt.ResultValidationFailure, "dane-no-match")
if err != nil {
// todo future: potentially add more details. e.g. dane-ta verification errors. tlsrpt does not have "result types" to indicate those kinds of errors. we would probably have to pass c.daneResult to dane.Verify.
// We may have encountered errors while evaluation some of the TLSA records.
fd.FailureReasonCode += "+errors"
}
c.hostResult.Add(0, 0, fd)
if c.ignoreTLSVerifyErrors {
// We ignore the failure and continue the connection.
c.log.Infox("verifying dane failed, continuing with connection", err)
MetricTLSRequiredNoIgnored.IncLabels("daneverification")
} else {
// This connection will fail.
daneErr = dane.ErrNoMatch
}
}
}
// PKIX verification.
opts := x509.VerifyOptions{
DNSName: cs.ServerName,
Intermediates: x509.NewCertPool(),
Roots: c.rootCAs,
}
for _, cert := range cs.PeerCertificates[1:] {
opts.Intermediates.AddCert(cert)
}
if _, err := cs.PeerCertificates[0].Verify(opts); err != nil {
resultType, reasonCode := tlsrpt.TLSFailureDetails(err)
fd := c.tlsrptFailureDetails(resultType, reasonCode)
c.recipientDomainResult.Add(0, 0, fd)
if c.tlsVerifyPKIX && !c.ignoreTLSVerifyErrors {
pkixErr = err
}
}
if daneErr != nil && pkixErr != nil {
return reportedError{errors.Join(daneErr, pkixErr)}
} else if daneErr != nil {
return reportedError{daneErr}
} else if pkixErr != nil {
return reportedError{pkixErr}
}
return nil
}
return &tls.Config{
ServerName: c.remoteHostname.ASCII, // For SNI.
// todo: possibly accept older TLS versions for TLSOpportunistic? or would our private key be at risk?
MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
InsecureSkipVerify: true, // VerifyConnection below is called and will do all verification.
VerifyConnection: verifyConnection,
}
}
// xbotchf generates a temporary error and marks the client as botched. e.g. for
// i/o errors or invalid protocol messages.
func (c *Client) xbotchf(code int, secode string, lastLine, format string, args ...any) {
panic(c.botchf(code, secode, lastLine, format, args...))
}
// botchf generates a temporary error and marks the client as botched. e.g. for
// i/o errors or invalid protocol messages.
func (c *Client) botchf(code int, secode string, lastLine, format string, args ...any) error {
c.botched = true
return c.errorf(false, code, secode, lastLine, format, args...)
}
func (c *Client) errorf(permanent bool, code int, secode, lastLine, format string, args ...any) error {
var cmd string
if len(c.cmds) > 0 {
cmd = c.cmds[0]
}
return Error{permanent, code, secode, cmd, lastLine, fmt.Errorf(format, args...)}
}
func (c *Client) xerrorf(permanent bool, code int, secode, lastLine, format string, args ...any) {
panic(c.errorf(permanent, code, secode, lastLine, format, args...))
}
// timeoutWriter passes each Write on to conn after setting a write deadline on conn based on
// timeout.
type timeoutWriter struct {
conn net.Conn
timeout time.Duration
log mlog.Log
}
func (w timeoutWriter) Write(buf []byte) (int, error) {
if err := w.conn.SetWriteDeadline(time.Now().Add(w.timeout)); err != nil {
w.log.Errorx("setting write deadline", err)
}
return w.conn.Write(buf)
}
var bufs = moxio.NewBufpool(8, 2*1024)
func (c *Client) readline() (string, error) {
// todo: could have per-operation timeouts. and rfc suggests higher minimum timeouts. ../rfc/5321:3610
if err := c.conn.SetReadDeadline(time.Now().Add(30 * time.Second)); err != nil {
c.log.Errorx("setting read deadline", err)
}
line, err := bufs.Readline(c.log, c.r)
if err != nil {
// See if this is a TLS alert from remote, and one other than 0 (which notifies
// that the connection is being closed. If so, we register a TLS connection
// failure. This handles TLS alerts that happen just after a successful handshake.
var netErr *net.OpError
if c.firstReadAfterHandshake && errors.As(err, &netErr) && netErr.Op == "remote error" && netErr.Err != nil && reflect.ValueOf(netErr.Err).Kind() == reflect.Uint8 && reflect.ValueOf(netErr.Err).Uint() != 0 {
resultType, reasonCode := tlsrpt.TLSFailureDetails(err)
// We count -1 success to compensate for the assumed success right after the handshake.
c.tlsResultAddFailureDetails(-1, 1, c.tlsrptFailureDetails(resultType, reasonCode))
}
return line, c.botchf(0, "", "", "%s: %w", strings.Join(c.cmds, ","), err)
}
c.firstReadAfterHandshake = false
return line, nil
}
func (c *Client) xtrace(level slog.Level) func() {
c.xflush()
c.tr.SetTrace(level)
c.tw.SetTrace(level)
return func() {
c.xflush()
c.tr.SetTrace(mlog.LevelTrace)
c.tw.SetTrace(mlog.LevelTrace)
}
}
func (c *Client) xwritelinef(format string, args ...any) {
c.xbwritelinef(format, args...)
c.xflush()
}
func (c *Client) xwriteline(line string) {
c.xbwriteline(line)
c.xflush()
}
func (c *Client) xbwritelinef(format string, args ...any) {
c.xbwriteline(fmt.Sprintf(format, args...))
}
func (c *Client) xbwriteline(line string) {
_, err := fmt.Fprintf(c.w, "%s\r\n", line)
if err != nil {
c.xbotchf(0, "", "", "write: %w", err)
}
}
func (c *Client) xflush() {
err := c.w.Flush()
if err != nil {
c.xbotchf(0, "", "", "writes: %w", err)
}
}
// read response, possibly multiline, with supporting extended codes based on configuration in client.
func (c *Client) xread() (code int, secode, lastLine string, texts []string) {
var err error
code, secode, lastLine, texts, err = c.read()
if err != nil {
panic(err)
}
return
}
func (c *Client) read() (code int, secode, lastLine string, texts []string, rerr error) {
return c.readecode(c.extEcodes)
}
// read response, possibly multiline.
// if ecodes, extended codes are parsed.
func (c *Client) readecode(ecodes bool) (code int, secode, lastLine string, texts []string, rerr error) {
for {
co, sec, text, line, last, err := c.read1(ecodes)
if err != nil {
rerr = err
return
}
texts = append(texts, text)
if code != 0 && co != code {
// ../rfc/5321:2771
err := c.botchf(0, "", line, "%w: multiline response with different codes, previous %d, last %d", ErrProtocol, code, co)
return 0, "", "", nil, err
}
code = co
if last {
if code != smtp.C334ContinueAuth {
cmd := ""
if len(c.cmds) > 0 {
cmd = c.cmds[0]
// We only keep the last, so we're not creating new slices all the time.
if len(c.cmds) > 1 {
c.cmds = c.cmds[1:]
}
}
MetricCommands.ObserveLabels(float64(time.Since(c.cmdStart))/float64(time.Second), cmd, fmt.Sprintf("%d", co), sec)
c.log.Debug("smtpclient command result",
slog.String("cmd", cmd),
slog.Int("code", co),
slog.String("secode", sec),
slog.Duration("duration", time.Since(c.cmdStart)))
}
return co, sec, line, texts, nil
}
}
}
func (c *Client) xreadecode(ecodes bool) (code int, secode, lastLine string, texts []string) {
var err error
code, secode, lastLine, texts, err = c.readecode(ecodes)
if err != nil {
panic(err)
}
return
}
// read single response line.
// if ecodes, extended codes are parsed.
func (c *Client) read1(ecodes bool) (code int, secode, text, line string, last bool, rerr error) {
line, rerr = c.readline()
if rerr != nil {
return
}
i := 0
for ; i < len(line) && line[i] >= '0' && line[i] <= '9'; i++ {
}
if i != 3 {
rerr = c.botchf(0, "", line, "%w: expected response code: %s", ErrProtocol, line)
return
}
v, err := strconv.ParseInt(line[:i], 10, 32)
if err != nil {
rerr = c.botchf(0, "", line, "%w: bad response code (%s): %s", ErrProtocol, err, line)
return
}
code = int(v)
major := code / 100
s := line[3:]
if strings.HasPrefix(s, "-") || strings.HasPrefix(s, " ") {
last = s[0] == ' '
s = s[1:]
} else if s == "" {
// Allow missing space. ../rfc/5321:2570 ../rfc/5321:2612
last = true
} else {
rerr = c.botchf(0, "", line, "%w: expected space or dash after response code: %s", ErrProtocol, line)
return
}
if ecodes {
secode, s = parseEcode(major, s)
}
return code, secode, s, line, last, nil
}
func parseEcode(major int, s string) (secode string, remain string) {
o := 0
bad := false
take := func(need bool, a, b byte) bool {
if !bad && o < len(s) && s[o] >= a && s[o] <= b {
o++
return true
}
bad = bad || need
return false
}
digit := func(need bool) bool {
return take(need, '0', '9')
}
dot := func() bool {
return take(true, '.', '.')
}
digit(true)
dot()
xo := o
digit(true)
for digit(false) {
}
dot()
digit(true)
for digit(false) {
}
secode = s[xo:o]
take(false, ' ', ' ')
if bad || int(s[0])-int('0') != major {
return "", s
}
return secode, s[o:]
}
func (c *Client) recover(rerr *error) {
x := recover()
if x == nil {
return
}
cerr, ok := x.(Error)
if !ok {
MetricPanicInc()
panic(x)
}
*rerr = cerr
}
func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Domain, auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)) (rerr error) {
defer c.recover(&rerr)
// perform EHLO handshake, falling back to HELO if server does not appear to
// implement EHLO.
hello := func(heloOK bool) {
// Write EHLO and parse the supported extensions.
// ../rfc/5321:987
c.cmds[0] = "ehlo"
c.cmdStart = time.Now()
// Syntax: ../rfc/5321:1827
c.xwritelinef("EHLO %s", ehloHostname.ASCII)
code, _, lastLine, remains := c.xreadecode(false)
switch code {
// ../rfc/5321:997
// ../rfc/5321:3098
case smtp.C500BadSyntax, smtp.C501BadParamSyntax, smtp.C502CmdNotImpl, smtp.C503BadCmdSeq, smtp.C504ParamNotImpl:
if !heloOK {
c.xerrorf(true, code, "", lastLine, "%w: remote claims ehlo is not supported", ErrProtocol)
}
// ../rfc/5321:996
c.cmds[0] = "helo"
c.cmdStart = time.Now()
c.xwritelinef("HELO %s", ehloHostname.ASCII)
code, _, lastLine, _ = c.xreadecode(false)
if code != smtp.C250Completed {
c.xerrorf(code/100 == 5, code, "", lastLine, "%w: expected 250 to HELO, got %d", ErrStatus, code)
}
return
case smtp.C250Completed:
default:
c.xerrorf(code/100 == 5, code, "", lastLine, "%w: expected 250, got %d", ErrStatus, code)
}
for _, s := range remains[1:] {
// ../rfc/5321:1869
s = strings.ToUpper(strings.TrimSpace(s))
switch s {
case "STARTTLS":
c.extStartTLS = true
case "ENHANCEDSTATUSCODES":
c.extEcodes = true
case "8BITMIME":
c.ext8bitmime = true
case "PIPELINING":
c.extPipelining = true
case "REQUIRETLS":
c.extRequireTLS = true
default:
// For SMTPUTF8 we must ignore any parameter. ../rfc/6531:207
if s == "SMTPUTF8" || strings.HasPrefix(s, "SMTPUTF8 ") {
c.extSMTPUTF8 = true
} else if strings.HasPrefix(s, "SIZE ") {
c.extSize = true
if v, err := strconv.ParseInt(s[len("SIZE "):], 10, 64); err == nil {
c.maxSize = v
}
} else if strings.HasPrefix(s, "AUTH ") {
c.extAuthMechanisms = strings.Split(s[len("AUTH "):], " ")
}
}
}
}
// Read greeting.
c.cmds = []string{"(greeting)"}
c.cmdStart = time.Now()
code, _, lastLine, lines := c.xreadecode(false)
if code != smtp.C220ServiceReady {
c.xerrorf(code/100 == 5, code, "", lastLine, "%w: expected 220, got %d", ErrStatus, code)
}
// ../rfc/5321:2588
c.remoteHelo, _, _ = strings.Cut(lines[0], " ")
// Write EHLO, falling back to HELO if server doesn't appear to support it.
hello(true)
// Attempt TLS if remote understands STARTTLS and we aren't doing immediate TLS or if caller requires it.
if c.extStartTLS && tlsMode == TLSOpportunistic || tlsMode == TLSRequiredStartTLS {
c.log.Debug("starting tls client", slog.Any("tlsmode", tlsMode), slog.Any("servername", c.remoteHostname))
c.cmds[0] = "starttls"
c.cmdStart = time.Now()
c.xwritelinef("STARTTLS")
code, secode, lastLine, _ := c.xread()
// ../rfc/3207:107
if code != smtp.C220ServiceReady {
c.tlsResultAddFailureDetails(0, 1, c.tlsrptFailureDetails(tlsrpt.ResultSTARTTLSNotSupported, fmt.Sprintf("smtp-starttls-reply-code-%d", code)))
c.xerrorf(code/100 == 5, code, secode, lastLine, "%w: STARTTLS: got %d, expected 220", ErrTLS, code)
}
// We don't want to do TLS on top of c.r because it also prints protocol traces: We
// don't want to log the TLS stream. So we'll do TLS on the underlying connection,
// but make sure any bytes already read and in the buffer are used for the TLS
// handshake.
conn := c.conn
if n := c.r.Buffered(); n > 0 {
conn = &moxio.PrefixConn{
PrefixReader: io.LimitReader(c.r, int64(n)),
Conn: conn,
}
}
tlsConfig := c.tlsConfig()
nconn := tls.Client(conn, tlsConfig)
c.conn = nconn
nctx, cancel := context.WithTimeout(ctx, time.Minute)
defer cancel()
err := nconn.HandshakeContext(nctx)
if err != nil {
// For each STARTTLS failure, we track a failed TLS session. For deliveries with
// multiple MX targets, we may add multiple failures, and delivery may succeed with
// a later MX target with which we can do STARTTLS. ../rfc/8460:524
c.tlsResultAdd(0, 1, err)
c.xerrorf(false, 0, "", "", "%w: STARTTLS TLS handshake: %s", ErrTLS, err)
}
c.firstReadAfterHandshake = true
cancel()
c.tr = moxio.NewTraceReader(c.log, "RS: ", c.conn)
c.tw = moxio.NewTraceWriter(c.log, "LC: ", c.conn) // No need to wrap in timeoutWriter, it would just set the timeout on the underlying connection, which is still active.
c.r = bufio.NewReader(c.tr)
c.w = bufio.NewWriter(c.tw)
tlsversion, ciphersuite := moxio.TLSInfo(nconn)
c.log.Debug("starttls client handshake done",
slog.Any("tlsmode", tlsMode),
slog.Bool("verifypkix", c.tlsVerifyPKIX),
slog.Bool("verifydane", c.daneRecords != nil),
slog.Bool("ignoretlsverifyerrors", c.ignoreTLSVerifyErrors),
slog.String("tls", tlsversion),
slog.String("ciphersuite", ciphersuite),
slog.Any("servername", c.remoteHostname),
slog.Any("danerecord", c.daneVerifiedRecord))
c.tls = true
// Track successful TLS connection. ../rfc/8460:515
c.tlsResultAdd(1, 0, nil)
hello(false)
} else if tlsMode == TLSOpportunistic {
// Result: ../rfc/8460:538
c.tlsResultAddFailureDetails(0, 0, c.tlsrptFailureDetails(tlsrpt.ResultSTARTTLSNotSupported, ""))
}
if auth != nil {
return c.auth(auth)
}
return
}
func addrIP(addr net.Addr) string {
if t, ok := addr.(*net.TCPAddr); ok {
return t.IP.String()
}
host, _, _ := net.SplitHostPort(addr.String())
ip := net.ParseIP(host)
if ip == nil {
return "" // For pipe during tests.
}
return ip.String()
}
// tlsrptFailureDetails returns FailureDetails with connection details (such as
// IP addresses) for inclusion in a TLS report.
func (c *Client) tlsrptFailureDetails(resultType tlsrpt.ResultType, reasonCode string) tlsrpt.FailureDetails {
return tlsrpt.FailureDetails{
ResultType: resultType,
SendingMTAIP: addrIP(c.origConn.LocalAddr()),
ReceivingMXHostname: c.remoteHostname.ASCII,
ReceivingMXHelo: c.remoteHelo,
ReceivingIP: addrIP(c.origConn.RemoteAddr()),
FailedSessionCount: 1,
FailureReasonCode: reasonCode,
}
}
// tlsResultAdd adds TLS success/failure to all results.
func (c *Client) tlsResultAdd(success, failure int64, err error) {
// Only track failure if not already done so in tls.Config.VerifyConnection.
var fds []tlsrpt.FailureDetails
var repErr reportedError
if err != nil && !errors.As(err, &repErr) {
resultType, reasonCode := tlsrpt.TLSFailureDetails(err)
fd := c.tlsrptFailureDetails(resultType, reasonCode)
fds = []tlsrpt.FailureDetails{fd}
}
c.tlsResultAddFailureDetails(success, failure, fds...)
}
func (c *Client) tlsResultAddFailureDetails(success, failure int64, fds ...tlsrpt.FailureDetails) {
c.recipientDomainResult.Add(success, failure, fds...)
c.hostResult.Add(success, failure, fds...)
}
// ../rfc/4954:139
func (c *Client) auth(auth func(mechanisms []string, cs *tls.ConnectionState) (sasl.Client, error)) (rerr error) {
defer c.recover(&rerr)
c.cmds[0] = "auth"
c.cmdStart = time.Now()
mechanisms := make([]string, len(c.extAuthMechanisms))
for i, m := range c.extAuthMechanisms {
mechanisms[i] = strings.ToUpper(m)
}
a, err := auth(mechanisms, c.TLSConnectionState())
if err != nil {
c.xerrorf(true, 0, "", "", "get authentication mechanism: %s, server supports %s", err, strings.Join(c.extAuthMechanisms, ", "))
} else if a == nil {
c.xerrorf(true, 0, "", "", "no matching authentication mechanisms, server supports %s", strings.Join(c.extAuthMechanisms, ", "))
}
name, cleartextCreds := a.Info()
abort := func() (int, string, string) {
// Abort authentication. ../rfc/4954:193
c.xwriteline("*")
// Server must respond with 501. // ../rfc/4954:195
code, secode, lastline, _ := c.xread()
if code != smtp.C501BadParamSyntax {
c.botched = true
}
return code, secode, lastline
}
toserver, last, err := a.Next(nil)
if err != nil {
c.xerrorf(false, 0, "", "", "initial step in auth mechanism %s: %w", name, err)
}
if cleartextCreds {
defer c.xtrace(mlog.LevelTraceauth)()
}
if toserver == nil {
c.xwriteline("AUTH " + name)
} else if len(toserver) == 0 {
c.xwriteline("AUTH " + name + " =") // ../rfc/4954:214
} else {
c.xwriteline("AUTH " + name + " " + base64.StdEncoding.EncodeToString(toserver))
}
for {
if cleartextCreds && last {
c.xtrace(mlog.LevelTrace) // Restore.
}
code, secode, lastLine, texts := c.xreadecode(last)
if code == smtp.C235AuthSuccess {
if !last {
c.xerrorf(false, code, secode, lastLine, "server completed authentication earlier than client expected")
}
return nil
} else if code == smtp.C334ContinueAuth {
if last {
c.xerrorf(false, code, secode, lastLine, "server requested unexpected continuation of authentication")
}
if len(texts) != 1 {
abort()
c.xerrorf(false, code, secode, lastLine, "server responded with multiline contination")
}
fromserver, err := base64.StdEncoding.DecodeString(texts[0])
if err != nil {
abort()
c.xerrorf(false, code, secode, lastLine, "malformed base64 data in authentication continuation response")
}
toserver, last, err = a.Next(fromserver)
if err != nil {
// For failing SCRAM, the client stops due to message about invalid proof. The
// server still sends an authentication result (it probably should send 501
// instead).
xcode, xsecode, lastline := abort()
c.xerrorf(false, xcode, xsecode, lastline, "client aborted authentication: %w", err)
}
c.xwriteline(base64.StdEncoding.EncodeToString(toserver))
} else {
c.xerrorf(code/100 == 5, code, secode, lastLine, "unexpected response during authentication, expected 334 continue or 235 auth success")
}
}
}
// Supports8BITMIME returns whether the SMTP server supports the 8BITMIME
// extension, needed for sending data with non-ASCII bytes.
func (c *Client) Supports8BITMIME() bool {
return c.ext8bitmime
}
// SupportsSMTPUTF8 returns whether the SMTP server supports the SMTPUTF8
// extension, needed for sending messages with UTF-8 in headers or in an (SMTP)
// address.
func (c *Client) SupportsSMTPUTF8() bool {
return c.extSMTPUTF8
}
// SupportsStartTLS returns whether the SMTP server supports the STARTTLS
// extension.
func (c *Client) SupportsStartTLS() bool {
return c.extStartTLS
}
// SupportsRequireTLS returns whether the SMTP server supports the REQUIRETLS
// extension. The REQUIRETLS extension is only announced after enabling
// STARTTLS.
func (c *Client) SupportsRequireTLS() bool {
return c.extRequireTLS
}
// TLSConnectionState returns TLS details if TLS is enabled, and nil otherwise.
func (c *Client) TLSConnectionState() *tls.ConnectionState {
if tlsConn, ok := c.conn.(*tls.Conn); ok {
cs := tlsConn.ConnectionState()
return &cs
}
return nil
}
// Deliver attempts to deliver a message to a mail server.
//
// mailFrom must be an email address, or empty in case of a DSN. rcptTo must be
// an email address.
//
// If the message contains bytes with the high bit set, req8bitmime must be true. If
// set, the remote server must support the 8BITMIME extension or delivery will
// fail.
//
// If the message is internationalized, e.g. when headers contain non-ASCII
// character, or when UTF-8 is used in a localpart, reqSMTPUTF8 must be true. If set,
// the remote server must support the SMTPUTF8 extension or delivery will fail.
//
// If requireTLS is true, the remote server must support the REQUIRETLS
// extension, or delivery will fail.
//
// Deliver uses the following SMTP extensions if the remote server supports them:
// 8BITMIME, SMTPUTF8, SIZE, PIPELINING, ENHANCEDSTATUSCODES, STARTTLS.
//
// Returned errors can be of type Error, one of the Err-variables in this package
// or other underlying errors, e.g. for i/o. Use errors.Is to check.
func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8, requireTLS bool) (rerr error) {
defer c.recover(&rerr)
if c.origConn == nil {
return ErrClosed
} else if c.botched {
return ErrBotched
} else if c.needRset {
if err := c.Reset(); err != nil {
return err
}
}
if !c.ext8bitmime && req8bitmime {
// Temporary error, e.g. OpenBSD spamd does not announce 8bitmime support, but once
// you get through, the mail server behind it probably does. Just needs a few
// retries.
c.xerrorf(false, 0, "", "", "%w", Err8bitmimeUnsupported)
}
if !c.extSMTPUTF8 && reqSMTPUTF8 {
// ../rfc/6531:313
c.xerrorf(false, 0, "", "", "%w", ErrSMTPUTF8Unsupported)
}
if !c.extRequireTLS && requireTLS {
c.xerrorf(false, 0, "", "", "%w", ErrRequireTLSUnsupported)
}
if c.extSize && msgSize > c.maxSize {
c.xerrorf(true, 0, "", "", "%w: message is %d bytes, remote has a %d bytes maximum size", ErrSize, msgSize, c.maxSize)
}
var mailSize, bodyType string
if c.extSize {
mailSize = fmt.Sprintf(" SIZE=%d", msgSize)
}
if c.ext8bitmime {
if req8bitmime {
bodyType = " BODY=8BITMIME"
} else {
bodyType = " BODY=7BIT"
}
}
var smtputf8Arg string
if reqSMTPUTF8 {
// ../rfc/6531:213
smtputf8Arg = " SMTPUTF8"
}
var requiretlsArg string
if requireTLS {
// ../rfc/8689:155
requiretlsArg = " REQUIRETLS"
}
// Transaction overview: ../rfc/5321:1015
// MAIL FROM: ../rfc/5321:1879
// RCPT TO: ../rfc/5321:1916
// DATA: ../rfc/5321:1992
lineMailFrom := fmt.Sprintf("MAIL FROM:<%s>%s%s%s%s", mailFrom, mailSize, bodyType, smtputf8Arg, requiretlsArg)
lineRcptTo := fmt.Sprintf("RCPT TO:<%s>", rcptTo)
// We are going into a transaction. We'll clear this when done.
c.needRset = true
if c.extPipelining {
c.cmds = []string{"mailfrom", "rcptto", "data"}
c.cmdStart = time.Now()
// todo future: write in a goroutine to prevent potential deadlock if remote does not consume our writes before expecting us to read. could potentially happen with greylisting and a small tcp send window?
c.xbwriteline(lineMailFrom)
c.xbwriteline(lineRcptTo)
c.xbwriteline("DATA")
c.xflush()
// We read the response to RCPT TO and DATA without panic on read error. Servers
// may be aborting the connection after a failed MAIL FROM, e.g. outlook when it
// has blocklisted your IP. We don't want the read for the response to RCPT TO to
// cause a read error as it would result in an unhelpful error message and a
// temporary instead of permanent error code.
mfcode, mfsecode, mflastline, _ := c.xread()
rtcode, rtsecode, rtlastline, _, rterr := c.read()
datacode, datasecode, datalastline, _, dataerr := c.read()
if mfcode != smtp.C250Completed {
c.xerrorf(mfcode/100 == 5, mfcode, mfsecode, mflastline, "%w: got %d, expected 2xx", ErrStatus, mfcode)
}
if rterr != nil {
panic(rterr)
}
if rtcode != smtp.C250Completed {
c.xerrorf(rtcode/100 == 5, rtcode, rtsecode, rtlastline, "%w: got %d, expected 2xx", ErrStatus, rtcode)
}
if dataerr != nil {
panic(dataerr)
}
if datacode != smtp.C354Continue {
c.xerrorf(datacode/100 == 5, datacode, datasecode, datalastline, "%w: got %d, expected 354", ErrStatus, datacode)
}
} else {
c.cmds[0] = "mailfrom"
c.cmdStart = time.Now()
c.xwriteline(lineMailFrom)
code, secode, lastline, _ := c.xread()
if code != smtp.C250Completed {
c.xerrorf(code/100 == 5, code, secode, lastline, "%w: got %d, expected 2xx", ErrStatus, code)
}
c.cmds[0] = "rcptto"
c.cmdStart = time.Now()
c.xwriteline(lineRcptTo)
code, secode, lastline, _ = c.xread()
if code != smtp.C250Completed {
c.xerrorf(code/100 == 5, code, secode, lastline, "%w: got %d, expected 2xx", ErrStatus, code)
}
c.cmds[0] = "data"
c.cmdStart = time.Now()
c.xwriteline("DATA")
code, secode, lastline, _ = c.xread()
if code != smtp.C354Continue {
c.xerrorf(code/100 == 5, code, secode, lastline, "%w: got %d, expected 354", ErrStatus, code)
}
}
// For a DATA write, the suggested timeout is 3 minutes, we use 30 seconds for all
// writes through timeoutWriter. ../rfc/5321:3651
defer c.xtrace(mlog.LevelTracedata)()
err := smtp.DataWrite(c.w, msg)
if err != nil {
c.xbotchf(0, "", "", "writing message as smtp data: %w", err)
}
c.xflush()
c.xtrace(mlog.LevelTrace) // Restore.
code, secode, lastline, _ := c.xread()
if code != smtp.C250Completed {
c.xerrorf(code/100 == 5, code, secode, lastline, "%w: got %d, expected 2xx", ErrStatus, code)
}
c.needRset = false
return
}
// Reset sends an SMTP RSET command to reset the message transaction state. Deliver
// automatically sends it if needed.
func (c *Client) Reset() (rerr error) {
if c.origConn == nil {
return ErrClosed
} else if c.botched {
return ErrBotched
}
defer c.recover(&rerr)
// ../rfc/5321:2079
c.cmds[0] = "rset"
c.cmdStart = time.Now()
c.xwriteline("RSET")
code, secode, lastline, _ := c.xread()
if code != smtp.C250Completed {
c.xerrorf(code/100 == 5, code, secode, lastline, "%w: got %d, expected 2xx", ErrStatus, code)
}
c.needRset = false
return
}
// Botched returns whether this connection is botched, e.g. a protocol error
// occurred and the connection is in unknown state, and cannot be used for message
// delivery.
func (c *Client) Botched() bool {
return c.botched || c.origConn == nil
}
// Close cleans up the client, closing the underlying connection.
//
// If the connection is initialized and not botched, a QUIT command is sent and the
// response read with a short timeout before closing the underlying connection.
//
// Close returns any error encountered during QUIT and closing.
func (c *Client) Close() (rerr error) {
if c.origConn == nil {
return ErrClosed
}
defer c.recover(&rerr)
if !c.botched {
// ../rfc/5321:2205
c.cmds[0] = "quit"
c.cmdStart = time.Now()
c.xwriteline("QUIT")
if err := c.conn.SetReadDeadline(time.Now().Add(5 * time.Second)); err != nil {
c.log.Infox("setting read deadline for reading quit response", err)
} else if _, err := bufs.Readline(c.log, c.r); err != nil {
rerr = fmt.Errorf("reading response to quit command: %v", err)
c.log.Debugx("reading quit response", err)
}
}
err := c.origConn.Close()
if c.conn != c.origConn {
// This is the TLS connection. Close will attempt to write a close notification.
// But it will fail quickly because the underlying socket was closed.
c.conn.Close()
}
c.origConn = nil
c.conn = nil
if rerr != nil {
rerr = err
}
return
}
// Conn returns the connection with initialized SMTP session. Once the caller uses
// this connection it is in control, and responsible for closing the connection,
// and other functions on the client must not be called anymore.
func (c *Client) Conn() (net.Conn, error) {
if err := c.conn.SetDeadline(time.Time{}); err != nil {
return nil, fmt.Errorf("clearing io deadlines: %w", err)
}
return c.conn, nil
}