mirror of
https://github.com/mjl-/mox.git
synced 2025-07-12 12:24:38 +03:00
new feature: when delivering messages from the queue, make it possible to use a "transport"
the default transport is still just "direct delivery", where we connect to the destination domain's MX servers. other transports are: - regular smtp without authentication, this is relaying to a smarthost. - submission with authentication, e.g. to a third party email sending service. - direct delivery, but with with connections going through a socks proxy. this can be helpful if your ip is blocked, you need to get email out, and you have another IP that isn't blocked. keep in mind that for all of the above, appropriate SPF/DKIM settings have to be configured. the "dnscheck" for a domain does a check for any SOCKS IP in the SPF record. SPF for smtp/submission (ranges? includes?) and any DKIM requirements cannot really be checked. which transport is used can be configured through routes. routes can be set on an account, a domain, or globally. the routes are evaluated in that order, with the first match selecting the transport. these routes are evaluated for each delivery attempt. common selection criteria are recipient domain and sender domain, but also which delivery attempt this is. you could configured mox to attempt sending through a 3rd party from the 4th attempt onwards. routes and transports are optional. if no route matches, or an empty/zero transport is selected, normal direct delivery is done. we could already "submit" emails with 3rd party accounts with "sendmail". but we now support more SASL authentication mechanisms with SMTP (not only PLAIN, but also SCRAM-SHA-256, SCRAM-SHA-1 and CRAM-MD5), which sendmail now also supports. sendmail will use the most secure mechanism supported by the server, or the explicitly configured mechanism. for issue #36 by dmikushin. also based on earlier discussion on hackernews.
This commit is contained in:
@ -5,6 +5,7 @@ import (
|
||||
"bufio"
|
||||
"context"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@ -16,10 +17,12 @@ import (
|
||||
"github.com/prometheus/client_golang/prometheus"
|
||||
"github.com/prometheus/client_golang/prometheus/promauto"
|
||||
|
||||
"github.com/mjl-/mox/dns"
|
||||
"github.com/mjl-/mox/metrics"
|
||||
"github.com/mjl-/mox/mlog"
|
||||
"github.com/mjl-/mox/mox-"
|
||||
"github.com/mjl-/mox/moxio"
|
||||
"github.com/mjl-/mox/sasl"
|
||||
"github.com/mjl-/mox/smtp"
|
||||
)
|
||||
|
||||
@ -55,8 +58,11 @@ var (
|
||||
type TLSMode string
|
||||
|
||||
const (
|
||||
// TLS with validated certificate is required: matching name, not expired, trusted by CA.
|
||||
TLSStrict TLSMode = "strict"
|
||||
// TLS with STARTTLS for MX SMTP servers, with validated certificate is required: matching name, not expired, trusted by CA.
|
||||
TLSStrictStartTLS TLSMode = "strictstarttls"
|
||||
|
||||
// TLS immediately ("implicit TLS"), with validated certificate is required: matching name, not expired, trusted by CA.
|
||||
TLSStrictImmediate TLSMode = "strictimmediate"
|
||||
|
||||
// Use TLS if remote claims to support it, but do not validate the certificate
|
||||
// (not trusted by CA, different host name or expired certificate is accepted).
|
||||
@ -89,13 +95,14 @@ type Client struct {
|
||||
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.
|
||||
|
||||
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.
|
||||
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.
|
||||
}
|
||||
|
||||
// Error represents a failure to deliver a message.
|
||||
@ -146,11 +153,11 @@ func (e Error) Error() string {
|
||||
// New initializes an SMTP session on the given connection, returning a client that
|
||||
// can be used to deliver messages.
|
||||
//
|
||||
// New reads the server greeting, identifies itself with a HELO or EHLO command,
|
||||
// initializes TLS 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.
|
||||
// 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 is outside the scope of the client. The queue
|
||||
// managing outgoing messages decides which host to deliver to, taking multiple MX
|
||||
@ -159,18 +166,17 @@ func (e Error) Error() string {
|
||||
//
|
||||
// tlsMode indicates if TLS is required, optional or should not be used. A
|
||||
// certificate is only validated (trusted, match remoteHostname and not expired)
|
||||
// for tls mode "required". By default, SMTP does not verify TLS for interopability
|
||||
// reasons, but MTA-STS or DANE can require it. If opportunistic TLS is used, and a
|
||||
// TLS error is encountered, the caller may want to try again (on a new connection)
|
||||
// without TLS.
|
||||
// for the strict tls modes. By default, SMTP does not verify TLS for
|
||||
// interopability reasons, but MTA-STS or DANE can require it. If opportunistic TLS
|
||||
// is used, and a TLS error is encountered, the caller may want to try again (on a
|
||||
// new connection) without TLS.
|
||||
//
|
||||
// If auth is non-empty, it is executed as a command after SMTP greeting/EHLO
|
||||
// initialization, before starting delivery. For authenticating to a submission
|
||||
// service with AUTH PLAIN, only meant for testing.
|
||||
func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, remoteHostname, auth string) (*Client, error) {
|
||||
// If auth is non-empty, authentication will be done with the first algorithm
|
||||
// supported by the server. If none of the algorithms are supported, an error is
|
||||
// returned.
|
||||
func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, ourHostname, remoteHostname dns.Domain, auth []sasl.Client) (*Client, error) {
|
||||
c := &Client{
|
||||
origConn: conn,
|
||||
conn: conn,
|
||||
lastlog: time.Now(),
|
||||
cmds: []string{"(none)"},
|
||||
}
|
||||
@ -182,6 +188,24 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, rem
|
||||
c.lastlog = now
|
||||
return l
|
||||
})
|
||||
|
||||
if tlsMode == TLSStrictImmediate {
|
||||
tlsconfig := tls.Config{
|
||||
ServerName: remoteHostname.ASCII,
|
||||
RootCAs: mox.Conf.Static.TLS.CertPool,
|
||||
MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
|
||||
}
|
||||
tlsconn := tls.Client(conn, &tlsconfig)
|
||||
if err := tlsconn.HandshakeContext(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.conn = tlsconn
|
||||
tlsversion, ciphersuite := mox.TLSInfo(tlsconn)
|
||||
c.log.Debug("tls client handshake done", mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", remoteHostname))
|
||||
} 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.
|
||||
@ -192,7 +216,7 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, rem
|
||||
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, remoteHostname, auth); err != nil {
|
||||
if err := c.hello(ctx, tlsMode, ourHostname, remoteHostname, auth); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
@ -324,16 +348,18 @@ func (c *Client) readecode(ecodes bool) (code int, secode, lastLine string, text
|
||||
}
|
||||
code = co
|
||||
if last {
|
||||
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:]
|
||||
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.WithLabelValues(cmd, fmt.Sprintf("%d", co), sec).Observe(float64(time.Since(c.cmdStart)) / float64(time.Second))
|
||||
c.log.Debug("smtpclient command result", mlog.Field("cmd", cmd), mlog.Field("code", co), mlog.Field("secode", sec), mlog.Field("duration", time.Since(c.cmdStart)))
|
||||
}
|
||||
metricCommands.WithLabelValues(cmd, fmt.Sprintf("%d", co), sec).Observe(float64(time.Since(c.cmdStart)) / float64(time.Second))
|
||||
c.log.Debug("smtpclient command result", mlog.Field("cmd", cmd), mlog.Field("code", co), mlog.Field("secode", sec), mlog.Field("duration", time.Since(c.cmdStart)))
|
||||
return co, sec, line, texts, nil
|
||||
}
|
||||
}
|
||||
@ -437,7 +463,7 @@ func (c *Client) recover(rerr *error) {
|
||||
*rerr = cerr
|
||||
}
|
||||
|
||||
func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, auth string) (rerr error) {
|
||||
func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ourHostname, remoteHostname dns.Domain, auth []sasl.Client) (rerr error) {
|
||||
defer c.recover(&rerr)
|
||||
|
||||
// perform EHLO handshake, falling back to HELO if server does not appear to
|
||||
@ -448,7 +474,7 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
c.cmds[0] = "ehlo"
|
||||
c.cmdStart = time.Now()
|
||||
// Syntax: ../rfc/5321:1827
|
||||
c.xwritelinef("EHLO %s", mox.Conf.Static.HostnameDomain.ASCII)
|
||||
c.xwritelinef("EHLO %s", ourHostname.ASCII)
|
||||
code, _, lastLine, remains := c.xreadecode(false)
|
||||
switch code {
|
||||
// ../rfc/5321:997
|
||||
@ -460,7 +486,7 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
// ../rfc/5321:996
|
||||
c.cmds[0] = "helo"
|
||||
c.cmdStart = time.Now()
|
||||
c.xwritelinef("HELO %s", mox.Conf.Static.HostnameDomain.ASCII)
|
||||
c.xwritelinef("HELO %s", ourHostname.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)
|
||||
@ -491,6 +517,8 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
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 "):], " ")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -507,8 +535,8 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
// Write EHLO, falling back to HELO if server doesn't appear to support it.
|
||||
hello(true)
|
||||
|
||||
// Attempt TLS if remote understands STARTTLS or if caller requires it.
|
||||
if c.extStartTLS && tlsMode != TLSSkip || tlsMode == TLSStrict {
|
||||
// Attempt TLS if remote understands STARTTLS and we aren't doing immediate TLS or if caller requires it.
|
||||
if c.extStartTLS && (tlsMode != TLSSkip && tlsMode != TLSStrictImmediate) || tlsMode == TLSStrictStartTLS {
|
||||
c.log.Debug("starting tls client", mlog.Field("tlsmode", tlsMode), mlog.Field("servername", remoteHostname))
|
||||
c.cmds[0] = "starttls"
|
||||
c.cmdStart = time.Now()
|
||||
@ -531,13 +559,13 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
}
|
||||
}
|
||||
|
||||
// For TLSStrict, the Go TLS library performs the checks needed for MTA-STS.
|
||||
// For TLSStrictStartTLS, the Go TLS library performs the checks needed for MTA-STS.
|
||||
// ../rfc/8461:646
|
||||
// todo: possibly accept older TLS versions for TLSOpportunistic?
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: remoteHostname,
|
||||
ServerName: remoteHostname.ASCII,
|
||||
RootCAs: mox.Conf.Static.TLS.CertPool,
|
||||
InsecureSkipVerify: tlsMode != TLSStrict,
|
||||
InsecureSkipVerify: tlsMode != TLSStrictStartTLS,
|
||||
MinVersion: tls.VersionTLS12, // ../rfc/8996:31 ../rfc/8997:66
|
||||
}
|
||||
nconn := tls.Client(conn, tlsConfig)
|
||||
@ -556,25 +584,103 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, remoteHostname, aut
|
||||
c.w = bufio.NewWriter(c.tw)
|
||||
|
||||
tlsversion, ciphersuite := mox.TLSInfo(nconn)
|
||||
c.log.Debug("tls client handshake done", mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", remoteHostname), mlog.Field("insecureskipverify", tlsConfig.InsecureSkipVerify))
|
||||
c.log.Debug("starttls client handshake done", mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", remoteHostname), mlog.Field("insecureskipverify", tlsConfig.InsecureSkipVerify))
|
||||
|
||||
hello(false)
|
||||
}
|
||||
|
||||
if auth != "" {
|
||||
// No metrics, only used for tests.
|
||||
c.cmds[0] = "auth"
|
||||
c.cmdStart = time.Now()
|
||||
defer c.xtrace(mlog.LevelTraceauth)()
|
||||
c.xwriteline(auth)
|
||||
c.xtrace(mlog.LevelTrace) // Restore.
|
||||
code, secode, lastLine, _ := c.xread()
|
||||
if code != smtp.C235AuthSuccess {
|
||||
c.xerrorf(code/100 == 5, code, secode, lastLine, "%w: auth: got %d, expected 2xx", ErrStatus, code)
|
||||
if len(auth) > 0 {
|
||||
return c.auth(auth)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ../rfc/4954:139
|
||||
func (c *Client) auth(auth []sasl.Client) (rerr error) {
|
||||
defer c.recover(&rerr)
|
||||
|
||||
c.cmds[0] = "auth"
|
||||
c.cmdStart = time.Now()
|
||||
|
||||
var a sasl.Client
|
||||
var name string
|
||||
var cleartextCreds bool
|
||||
for _, x := range auth {
|
||||
name, cleartextCreds = x.Info()
|
||||
for _, s := range c.extAuthMechanisms {
|
||||
if s == name {
|
||||
a = x
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if a == nil {
|
||||
c.xerrorf(true, 0, "", "", "no matching authentication mechanisms, server supports %s", strings.Join(c.extAuthMechanisms, ", "))
|
||||
}
|
||||
|
||||
return
|
||||
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
|
||||
|
Reference in New Issue
Block a user