implement "requiretls", rfc 8689

with requiretls, the tls verification mode/rules for email deliveries can be
changed by the sender/submitter. in two ways:

1. "requiretls" smtp extension to always enforce verified tls (with mta-sts or
dnssec+dane), along the entire delivery path until delivery into the final
destination mailbox (so entire transport is verified-tls-protected).

2. "tls-required: no" message header, to ignore any tls and tls verification
errors even if the recipient domain has a policy that requires tls verification
(mta-sts and/or dnssec+dane), allowing delivery of non-sensitive messages in
case of misconfiguration/interoperability issues (at least useful for sending
tls reports).

we enable requiretls by default (only when tls is active), for smtp and
submission. it can be disabled through the config.

for each delivery attempt, we now store (per recipient domain, in the account
of the sender) whether the smtp server supports starttls and requiretls. this
support is shown (after having sent a first message) in the webmail when
sending a message (the previous 3 bars under the address input field are now 5
bars, the first for starttls support, the last for requiretls support). when
all recipient domains for a message are known to implement requiretls,
requiretls is automatically selected for sending (instead of "default" tls
behaviour). users can also select the "fallback to insecure" to add the
"tls-required: no" header.

new metrics are added for insight into requiretls errors and (some, not yet
all) cases where tls-required-no ignored a tls/verification error.

the admin can change the requiretls status for messages in the queue. so with
default delivery attempts, when verified tls is required by failing, an admin
could potentially change the field to "tls-required: no"-behaviour.

messages received (over smtp) with the requiretls option, get a comment added
to their Received header line, just before "id", after "with".
This commit is contained in:
Mechiel Lukkien 2023-10-24 10:06:16 +02:00
parent 0e5e16b3d0
commit 2f5d6069bf
No known key found for this signature in database
31 changed files with 1102 additions and 274 deletions

View file

@ -20,7 +20,7 @@ See Quickstart below to get started.
("localparts"), and in domain names (IDNA). ("localparts"), and in domain names (IDNA).
- Automatic TLS with ACME, for use with Let's Encrypt and other CA's. - Automatic TLS with ACME, for use with Let's Encrypt and other CA's.
- DANE and MTA-STS for inbound and outbound delivery over SMTP with STARTTLS, - DANE and MTA-STS for inbound and outbound delivery over SMTP with STARTTLS,
with incoming TLSRPT reporting. including REQUIRETLS and with incoming TLSRPT reporting.
- Web admin interface that helps you set up your domains and accounts - Web admin interface that helps you set up your domains and accounts
(instructions to create DNS records, configure (instructions to create DNS records, configure
SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing

View file

@ -123,10 +123,14 @@ type Listener struct {
SMTPMaxMessageSize int64 `sconf:"optional" sconf-doc:"Maximum size in bytes for incoming and outgoing messages. Default is 100MB."` SMTPMaxMessageSize int64 `sconf:"optional" sconf-doc:"Maximum size in bytes for incoming and outgoing messages. Default is 100MB."`
SMTP struct { SMTP struct {
Enabled bool Enabled bool
Port int `sconf:"optional" sconf-doc:"Default 25."` Port int `sconf:"optional" sconf-doc:"Default 25."`
NoSTARTTLS bool `sconf:"optional" sconf-doc:"Do not offer STARTTLS to secure the connection. Not recommended."` NoSTARTTLS bool `sconf:"optional" sconf-doc:"Do not offer STARTTLS to secure the connection. Not recommended."`
RequireSTARTTLS bool `sconf:"optional" sconf-doc:"Do not accept incoming messages if STARTTLS is not active. Can be used in combination with a strict MTA-STS policy. A remote SMTP server may not support TLS and may not be able to deliver messages."` RequireSTARTTLS bool `sconf:"optional" sconf-doc:"Do not accept incoming messages if STARTTLS is not active. Can be used in combination with a strict MTA-STS policy. A remote SMTP server may not support TLS and may not be able to deliver messages."`
DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."` NoRequireTLS bool `sconf:"optional" sconf-doc:"Do not announce the REQUIRETLS SMTP extension. Messages delivered using the REQUIRETLS extension should only be distributed onwards to servers also implementing the REQUIRETLS extension. In some situations, such as hosting mailing lists, this may not be feasible due to lack of support for the extension by mailing list subscribers."`
// Reoriginated messages (such as messages sent to mailing list subscribers) should
// keep REQUIRETLS. ../rfc/8689:412
DNSBLs []string `sconf:"optional" sconf-doc:"Addresses of DNS block lists for incoming messages. Block lists are only consulted for connections/messages without enough reputation to make an accept/reject decision. This prevents sending IPs of all communications to the block list provider. If any of the listed DNSBLs contains a requested IP address, the message is rejected as spam. The DNSBLs are checked for healthiness before use, at most once per 4 hours. Example DNSBLs: sbl.spamhaus.org, bl.spamcop.net. See https://www.spamhaus.org/sbl/ and https://www.spamcop.net/ for more information and terms of use."`
FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."` FirstTimeSenderDelay *time.Duration `sconf:"optional" sconf-doc:"Delay before accepting a message from a first-time sender for the destination account. Default: 15s."`

View file

@ -184,6 +184,13 @@ describe-static" and "mox config describe-domains":
# TLS and may not be able to deliver messages. (optional) # TLS and may not be able to deliver messages. (optional)
RequireSTARTTLS: false RequireSTARTTLS: false
# Do not announce the REQUIRETLS SMTP extension. Messages delivered using the
# REQUIRETLS extension should only be distributed onwards to servers also
# implementing the REQUIRETLS extension. In some situations, such as hosting
# mailing lists, this may not be feasible due to lack of support for the extension
# by mailing list subscribers. (optional)
NoRequireTLS: false
# Addresses of DNS block lists for incoming messages. Block lists are only # Addresses of DNS block lists for incoming messages. Block lists are only
# consulted for connections/messages without enough reputation to make an # consulted for connections/messages without enough reputation to make an
# accept/reject decision. This prevents sending IPs of all communications to the # accept/reject decision. This prevents sending IPs of all communications to the

View file

@ -297,7 +297,7 @@ func Dial(ctx context.Context, resolver dns.Resolver, network, address string, a
// verified, if any. // verified, if any.
func TLSClientConfig(log *mlog.Log, records []adns.TLSA, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, verifiedRecord *adns.TLSA) tls.Config { func TLSClientConfig(log *mlog.Log, records []adns.TLSA, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, verifiedRecord *adns.TLSA) tls.Config {
return tls.Config{ return tls.Config{
ServerName: allowedHost.ASCII, ServerName: allowedHost.ASCII, // For SNI.
InsecureSkipVerify: true, InsecureSkipVerify: true,
VerifyConnection: func(cs tls.ConnectionState) error { VerifyConnection: func(cs tls.ConnectionState) error {
verified, record, err := Verify(log, records, cs, allowedHost, moreAllowedHosts) verified, record, err := Verify(log, records, cs, allowedHost, moreAllowedHosts)

View file

@ -233,7 +233,7 @@ Accounts:
const qmsg = "From: <test0@mox.example>\r\nTo: <other@remote.example>\r\nSubject: test\r\n\r\nthe message...\r\n" const qmsg = "From: <test0@mox.example>\r\nTo: <other@remote.example>\r\nSubject: test\r\n\r\nthe message...\r\n"
_, err = fmt.Fprint(mf, qmsg) _, err = fmt.Fprint(mf, qmsg)
xcheckf(err, "writing message") xcheckf(err, "writing message")
_, err = queue.Add(ctxbg, log, "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "<test@localhost>", prefix, mf, nil) _, err = queue.Add(ctxbg, log, "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "<test@localhost>", prefix, mf, nil, nil)
xcheckf(err, "enqueue message") xcheckf(err, "enqueue message")
// Create three accounts. // Create three accounts.

View file

@ -131,7 +131,7 @@ This is the message.
auth := []sasl.Client{sasl.NewClientPlain(mailfrom, password)} auth := []sasl.Client{sasl.NewClientPlain(mailfrom, password)}
c, err := smtpclient.New(mox.Context, xlog, conn, smtpclient.TLSSkip, ourHostname, dns.Domain{ASCII: desthost}, auth, nil, nil, nil) c, err := smtpclient.New(mox.Context, xlog, conn, smtpclient.TLSSkip, ourHostname, dns.Domain{ASCII: desthost}, auth, nil, nil, nil)
tcheck(t, err, "smtp hello") tcheck(t, err, "smtp hello")
err = c.Deliver(mox.Context, mailfrom, rcptto, int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(mox.Context, mailfrom, rcptto, int64(len(msg)), strings.NewReader(msg), false, false, false)
tcheck(t, err, "deliver with smtp") tcheck(t, err, "deliver with smtp")
err = c.Close() err = c.Close()
tcheck(t, err, "close smtpclient") tcheck(t, err, "close smtpclient")

View file

@ -130,6 +130,7 @@ var commands = []struct {
{"cid", cmdCid}, {"cid", cmdCid},
{"clientconfig", cmdClientConfig}, {"clientconfig", cmdClientConfig},
{"deliver", cmdDeliver}, {"deliver", cmdDeliver},
// todo: turn cmdDANEDialmx into a regular "dialmx" command that follows mta-sts policy, with options to require dane, mta-sts or requiretls. the code will be similar to queue/direct.go
{"dane dial", cmdDANEDial}, {"dane dial", cmdDANEDial},
{"dane dialmx", cmdDANEDialmx}, {"dane dialmx", cmdDANEDialmx},
{"dane makerecord", cmdDANEMakeRecord}, {"dane makerecord", cmdDANEMakeRecord},

View file

@ -23,6 +23,7 @@ import (
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/mtasts"
"github.com/mjl-/mox/mtastsdb" "github.com/mjl-/mox/mtastsdb"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient" "github.com/mjl-/mox/smtpclient"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
) )
@ -58,13 +59,36 @@ var (
Help: "Total number of connections where looking up TLSA records resulted in an error.", Help: "Total number of connections where looking up TLSA records resulted in an error.",
}, },
) )
// todo: recognize when "tls-required-no" message header caused a non-verifying certificate to be overridden. requires doing our own certificate validation after having set tls.Config.InsecureSkipVerify due to tls-required-no.
metricTLSRequiredNoIgnored = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_queue_tlsrequiredno_ignored_total",
Help: "Delivery attempts with TLS policy findings ignored due to message with TLS-Required: No header. Does not cover case where TLS certificate cannot be PKIX-verified.",
},
[]string{
"ignored", // mtastspolicy (error getting policy), mtastsmx (mx host not allowed in policy), badtls (error negotiating tls), badtlsa (error fetching dane tlsa records)
},
)
metricRequireTLSUnsupported = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_queue_requiretls_unsupported_total",
Help: "Delivery attempts that failed due to message with REQUIRETLS.",
},
[]string{
"reason", // nopolicy (no mta-sts and no dane), norequiretls (smtp server does not support requiretls)
},
)
) )
// todo: rename function, perhaps put some of the params in a delivery struct so we don't pass all the params all the time? // todo: rename function, perhaps put some of the params in a delivery struct so we don't pass all the params all the time?
func fail(qlog *mlog.Log, m Msg, backoff time.Duration, permanent bool, remoteMTA dsn.NameIP, secodeOpt, errmsg string) { func fail(qlog *mlog.Log, m Msg, backoff time.Duration, permanent bool, remoteMTA dsn.NameIP, secodeOpt, errmsg string) {
// todo future: when we implement relaying, we should be able to send DSNs to non-local users. and possibly specify a null mailfrom. ../rfc/5321:1503
// todo future: when we implement relaying, and a dsn cannot be delivered, and requiretls was active, we cannot drop the message. instead deliver to local postmaster? though ../rfc/8689:383 may intend to say the dsn should be delivered without requiretls?
// todo future: when we implement smtp dsn extension, parameter RET=FULL must be disregarded for messages with REQUIRETLS. ../rfc/8689:379
if permanent || m.Attempts >= 8 { if permanent || m.Attempts >= 8 {
qlog.Errorx("permanent failure delivering from queue", errors.New(errmsg)) qlog.Errorx("permanent failure delivering from queue", errors.New(errmsg))
queueDSNFailure(qlog, m, remoteMTA, secodeOpt, errmsg) deliverDSNFailure(qlog, m, remoteMTA, secodeOpt, errmsg)
if err := queueDelete(context.Background(), m.ID); err != nil { if err := queueDelete(context.Background(), m.ID); err != nil {
qlog.Errorx("deleting message from queue after permanent failure", err) qlog.Errorx("deleting message from queue after permanent failure", err)
@ -84,7 +108,7 @@ func fail(qlog *mlog.Log, m Msg, backoff time.Duration, permanent bool, remoteMT
qlog.Errorx("temporary failure delivering from queue, sending delayed dsn", errors.New(errmsg), mlog.Field("backoff", backoff)) qlog.Errorx("temporary failure delivering from queue, sending delayed dsn", errors.New(errmsg), mlog.Field("backoff", backoff))
retryUntil := m.LastAttempt.Add((4 + 8 + 16) * time.Hour) retryUntil := m.LastAttempt.Add((4 + 8 + 16) * time.Hour)
queueDSNDelay(qlog, m, remoteMTA, secodeOpt, errmsg, retryUntil) deliverDSNDelay(qlog, m, remoteMTA, secodeOpt, errmsg, retryUntil)
} else { } else {
qlog.Errorx("temporary failure delivering from queue", errors.New(errmsg), mlog.Field("backoff", backoff), mlog.Field("nextattempt", m.NextAttempt)) qlog.Errorx("temporary failure delivering from queue", errors.New(errmsg), mlog.Field("backoff", backoff), mlog.Field("nextattempt", m.NextAttempt))
} }
@ -102,6 +126,10 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
// successful delivery. But hopefully the delivery just succeeds. For each host: // successful delivery. But hopefully the delivery just succeeds. For each host:
// - If there is an MTA-STS policy, we only connect to allow-listed hosts. // - If there is an MTA-STS policy, we only connect to allow-listed hosts.
// - We try to lookup DANE records (optional) and verify them if present. // - We try to lookup DANE records (optional) and verify them if present.
// - If RequireTLS is true, we only deliver if the remote SMTP server implements it.
// - If RequireTLS is false, we'll fall back to regular delivery attempts without
// TLS verification and possibly without TLS at all, ignoring recipient domain/host
// MTA-STS and DANE policies.
// Resolve domain and hosts to attempt delivery to. // Resolve domain and hosts to attempt delivery to.
// These next-hop names are often the name under which we find MX records. The // These next-hop names are often the name under which we find MX records. The
@ -125,9 +153,14 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
cidctx := context.WithValue(mox.Shutdown, mlog.CidKey, cid) cidctx := context.WithValue(mox.Shutdown, mlog.CidKey, cid)
policy, _, err = mtastsdb.Get(cidctx, resolver, origNextHop) policy, _, err = mtastsdb.Get(cidctx, resolver, origNextHop)
if err != nil { if err != nil {
qlog.Infox("mtasts lookup temporary error, aborting delivery attempt", err, mlog.Field("domain", origNextHop)) if m.RequireTLS != nil && !*m.RequireTLS {
fail(qlog, m, backoff, false, dsn.NameIP{}, "", err.Error()) qlog.Infox("mtasts lookup temporary error, continuing due to tls-required-no message header", err, mlog.Field("domain", origNextHop))
return metricTLSRequiredNoIgnored.WithLabelValues("mtastspolicy").Inc()
} else {
qlog.Infox("mtasts lookup temporary error, aborting delivery attempt", err, mlog.Field("domain", origNextHop))
fail(qlog, m, backoff, false, dsn.NameIP{}, "", err.Error())
return
}
} }
// note: policy can be nil, if a domain does not implement MTA-STS or it's the // note: policy can be nil, if a domain does not implement MTA-STS or it's the
// first time we fetch the policy and if we encountered an error. // first time we fetch the policy and if we encountered an error.
@ -144,6 +177,7 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
var remoteMTA dsn.NameIP var remoteMTA dsn.NameIP
var secodeOpt, errmsg string var secodeOpt, errmsg string
permanent = false permanent = false
nmissingRequireTLS := 0
// todo: should make distinction between host permanently not accepting the message, and the message not being deliverable permanently. e.g. a mx host may have a size limit, or not accept 8bitmime, while another host in the list does accept the message. same for smtputf8, ../rfc/6531:555 // todo: should make distinction between host permanently not accepting the message, and the message not being deliverable permanently. e.g. a mx host may have a size limit, or not accept 8bitmime, while another host in the list does accept the message. same for smtputf8, ../rfc/6531:555
for _, h := range hosts { for _, h := range hosts {
var badTLS, ok bool var badTLS, ok bool
@ -155,11 +189,17 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
policyHosts = append(policyHosts, mx.LogString()) policyHosts = append(policyHosts, mx.LogString())
} }
if policy.Mode == mtasts.ModeEnforce { if policy.Mode == mtasts.ModeEnforce {
errmsg = fmt.Sprintf("mx host %s does not match enforced mta-sts policy with hosts %s", h.Domain, strings.Join(policyHosts, ",")) if m.RequireTLS != nil && !*m.RequireTLS {
qlog.Error("mx host does not match mta-sts policy in mode enforce, skipping", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts)) qlog.Info("mx host does not match mta-sts policy in mode enforce, ignoring due to tls-required-no message header", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts))
continue metricTLSRequiredNoIgnored.WithLabelValues("mtastsmx").Inc()
} else {
errmsg = fmt.Sprintf("mx host %s does not match enforced mta-sts policy with hosts %s", h.Domain, strings.Join(policyHosts, ","))
qlog.Error("mx host does not match mta-sts policy in mode enforce, skipping", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts))
continue
}
} else {
qlog.Error("mx host does not match mta-sts policy, but it is not enforced, continuing", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts))
} }
qlog.Error("mx host does not match mta-sts policy, but it is not enforced, continuing", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts))
} }
qlog.Info("delivering to remote", mlog.Field("remote", h), mlog.Field("queuecid", cid)) qlog.Info("delivering to remote", mlog.Field("remote", h), mlog.Field("queuecid", cid))
@ -190,10 +230,14 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
// old server that only does ancient TLS versions, or has a misconfiguration. Note // old server that only does ancient TLS versions, or has a misconfiguration. Note
// that opportunistic TLS does not do regular certificate verification, so that can't // that opportunistic TLS does not do regular certificate verification, so that can't
// be the problem. // be the problem.
if !ok && badTLS && !enforceMTASTS && tlsMode == smtpclient.TLSOpportunistic && !daneRequired { if !ok && badTLS && (!enforceMTASTS && tlsMode == smtpclient.TLSOpportunistic && !daneRequired || m.RequireTLS != nil && !*m.RequireTLS) {
if m.RequireTLS != nil && !*m.RequireTLS {
metricTLSRequiredNoIgnored.WithLabelValues("badtls").Inc()
}
// In case of failure with opportunistic TLS, try again without TLS. ../rfc/7435:459 // In case of failure with opportunistic TLS, try again without TLS. ../rfc/7435:459
// todo future: add a configuration option to not fall back? // todo future: add a configuration option to not fall back?
nqlog.Info("connecting again for delivery attempt without tls") nqlog.Info("connecting again for delivery attempt without tls", mlog.Field("enforcemtasts", enforceMTASTS), mlog.Field("danerequired", daneRequired), mlog.Field("requiretls", m.RequireTLS))
tlsMode = smtpclient.TLSSkip tlsMode = smtpclient.TLSSkip
permanent, _, _, secodeOpt, remoteIP, errmsg, ok = deliverHost(nqlog, resolver, dialer, cid, ourHostname, transportName, h, enforceMTASTS, haveMX, origNextHopAuthentic, origNextHop, expandedNextHopAuthentic, expandedNextHop, &m, tlsMode) permanent, _, _, secodeOpt, remoteIP, errmsg, ok = deliverHost(nqlog, resolver, dialer, cid, ourHostname, transportName, h, enforceMTASTS, haveMX, origNextHopAuthentic, origNextHop, expandedNextHopAuthentic, expandedNextHop, &m, tlsMode)
} }
@ -209,6 +253,9 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
if permanent { if permanent {
break break
} }
if secodeOpt == smtp.SePol7MissingReqTLS {
nmissingRequireTLS++
}
} }
// In theory, we could make a failure permanent if we didn't find any mx host // In theory, we could make a failure permanent if we didn't find any mx host
@ -220,13 +267,27 @@ func deliverDirect(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
// failures. // failures.
// todo: possibly detect that future deliveries will fail due to long ttl's of cached records that are preventing delivery. // todo: possibly detect that future deliveries will fail due to long ttl's of cached records that are preventing delivery.
// If we failed due to requiretls not being satisfied, make the delivery permanent.
// It is unlikely the recipient domain will implement requiretls during our retry
// period. Best to let the sender know immediately.
if !permanent && nmissingRequireTLS > 0 && nmissingRequireTLS == len(hosts) {
qlog.Info("marking delivery as permanently failed because recipient domain does not implement requiretls")
permanent = true
}
fail(qlog, m, backoff, permanent, remoteMTA, secodeOpt, errmsg) fail(qlog, m, backoff, permanent, remoteMTA, secodeOpt, errmsg)
} }
// deliverHost attempts to deliver m to host. Depending on tlsMode, we'll do // deliverHost attempts to deliver m to host. Depending on tlsMode, we'll do
// required TLS with WebPKI verification (with MTA-STS), opportunistic DANE TLS // required TLS with WebPKI verification (with MTA-STS), opportunistic DANE TLS
// (opportunistic TLS) or non-verifying TLS (opportunistic TLS) deliverHost updates // (opportunistic TLS), non-verifying TLS (opportunistic TLS) or skip TLS
// m.DialedIPs, which must be saved in case of failure to deliver. // altogether due to previous TLS errors.
//
// deliverHost updates m.DialedIPs, which must be saved in case of failure to
// deliver.
//
// With TLS-Required no header, we ignore verification failures and continue
// delivering.
// //
// The haveMX and next-hop-authentic fields are used to determine if DANE is // The haveMX and next-hop-authentic fields are used to determine if DANE is
// applicable. The next-hop fields themselves are used to determine valid names // applicable. The next-hop fields themselves are used to determine valid names
@ -282,14 +343,13 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
if err == nil && authentic && origNextHopAuthentic && (!haveMX || expandedNextHopAuthentic) && host.IsDomain() { if err == nil && authentic && origNextHopAuthentic && (!haveMX || expandedNextHopAuthentic) && host.IsDomain() {
metricDestinationsAuthentic.Inc() metricDestinationsAuthentic.Inc()
// Modes to skip and not verify aren't normally set when we get here. But in the
// future may perhaps be set on a message manually after delivery failures. We can
// handle them here.
switch tlsMode { switch tlsMode {
case smtpclient.TLSSkip: case smtpclient.TLSSkip:
// No TLS, so clearly no DANE. // No TLS, so clearly no DANE. This can happen if we've dialed TLS before but a TLS
// connection couldn't be established.
case smtpclient.TLSUnverifiedStartTLS: case smtpclient.TLSUnverifiedStartTLS:
// Fallback mode for DANE without usable records, so skip DANE. // Fallback mode for DANE without usable records, so skip DANE.
// We shouldn't be able to get here, but no harm handling it.
default: default:
// Look for TLSA records in either the expandedHost, or otherwise the original // Look for TLSA records in either the expandedHost, or otherwise the original
// host. ../rfc/7672:912 // host. ../rfc/7672:912
@ -324,6 +384,11 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
} else if !daneRequired { } else if !daneRequired {
log.Debugx("not doing opportunistic dane after gathering tlsa records", err) log.Debugx("not doing opportunistic dane after gathering tlsa records", err)
err = nil err = nil
} else if err != nil && m.RequireTLS != nil && !*m.RequireTLS {
log.Debugx("error gathering dane tlsa records with dane required, but continuing without validation due to tls-required-no message header", err)
daneRecords = nil
err = nil
metricTLSRequiredNoIgnored.WithLabelValues("badtlsa").Inc()
} }
// else, err is propagated below. // else, err is propagated below.
} }
@ -331,6 +396,16 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
log.Debugx("not attempting verification with dane", err, mlog.Field("authentic", authentic), mlog.Field("expandedauthentic", expandedAuthentic)) log.Debugx("not attempting verification with dane", err, mlog.Field("authentic", authentic), mlog.Field("expandedauthentic", expandedAuthentic))
} }
// todo: for requiretls, should an MTA-STS policy in mode testing be treated as good enough for requiretls? let's be strict and assume not.
// todo: ../rfc/8689:276 seems to specify stricter requirements on name in certificate than DANE (which allows original recipient domain name and cname-expanded name, and hints at following CNAME for MX targets as well, allowing both their original and expanded names too). perhaps the intent was just to say the name must be validated according to the relevant specifications?
// todo: for requiretls, should we allow no usable dane records with requiretls? dane allows it, but doesn't seem in spirit of requiretls, so not allowing it.
if err == nil && m.RequireTLS != nil && *m.RequireTLS && !(daneRequired && len(daneRecords) > 0) && !enforceMTASTS {
log.Info("verified tls is required, but destination has no usable dane records and no mta-sts policy, canceling delivery attempt to host")
metricRequireTLSUnsupported.WithLabelValues("nopolicy").Inc()
// Resond with proper enhanced status code. ../rfc/8689:301
return false, daneRequired, false, smtp.SePol7MissingReqTLS, remoteIP, "missing required tls verification mechanism", false
}
// Dial the remote host given the IPs if no error yet. // Dial the remote host given the IPs if no error yet.
var conn net.Conn var conn net.Conn
if err == nil { if err == nil {
@ -380,6 +455,9 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
moreHosts = tlsRemoteHostnames[1:] moreHosts = tlsRemoteHostnames[1:]
} }
var verifiedRecord adns.TLSA var verifiedRecord adns.TLSA
if m.RequireTLS != nil && !*m.RequireTLS && tlsMode != smtpclient.TLSSkip {
tlsMode = smtpclient.TLSUnverifiedStartTLS
}
sc, err := smtpclient.New(ctx, log, conn, tlsMode, ourHostname, firstHost, nil, daneRecords, moreHosts, &verifiedRecord) sc, err := smtpclient.New(ctx, log, conn, tlsMode, ourHostname, firstHost, nil, daneRecords, moreHosts, &verifiedRecord)
defer func() { defer func() {
if sc == nil { if sc == nil {
@ -389,6 +467,21 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
} }
mox.Connections.Unregister(conn) mox.Connections.Unregister(conn)
}() }()
if err == nil && m.SenderAccount != "" {
// Remember the STARTTLS and REQUIRETLS support for this recipient domain.
// It is used in the webmail client, to show the recipient domain security mechanisms.
// We always save only the last connection we actually encountered. There may be
// multiple MX hosts, perhaps only some support STARTTLS and REQUIRETLS. We may not
// be accurate for the whole domain, but we're only storing a hint.
rdt := store.RecipientDomainTLS{
Domain: m.RecipientDomain.Domain.Name(),
STARTTLS: sc.TLSEnabled(),
RequireTLS: sc.SupportsRequireTLS(),
}
if err = updateRecipientDomainTLS(ctx, m.SenderAccount, rdt); err != nil {
err = fmt.Errorf("storing recipient domain tls status: %w", err)
}
}
if err == nil { if err == nil {
// SMTP session is ready. Finally try to actually deliver. // SMTP session is ready. Finally try to actually deliver.
has8bit := m.Has8bit has8bit := m.Has8bit
@ -401,7 +494,7 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
size = int64(len(m.DSNUTF8)) size = int64(len(m.DSNUTF8))
msg = bytes.NewReader(m.DSNUTF8) msg = bytes.NewReader(m.DSNUTF8)
} }
err = sc.Deliver(ctx, mailFrom, rcptTo, size, msg, has8bit, smtputf8) err = sc.Deliver(ctx, mailFrom, rcptTo, size, msg, has8bit, smtputf8, m.RequireTLS != nil && *m.RequireTLS)
} }
if err != nil { if err != nil {
log.Infox("delivery failed", err) log.Infox("delivery failed", err)
@ -433,8 +526,34 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer,
if permanent && m.Attempts == 1 && dualstack && strings.HasPrefix(cerr.Secode, "7.") { if permanent && m.Attempts == 1 && dualstack && strings.HasPrefix(cerr.Secode, "7.") {
permanent = false permanent = false
} }
return permanent, daneRequired, errors.Is(cerr, smtpclient.ErrTLS), cerr.Secode, remoteIP, cerr.Error(), false // If server does not implement requiretls, respond with that code. ../rfc/8689:301
secode := cerr.Secode
if errors.Is(cerr.Err, smtpclient.ErrRequireTLSUnsupported) {
secode = smtp.SePol7MissingReqTLS
metricRequireTLSUnsupported.WithLabelValues("norequiretls").Inc()
}
return permanent, daneRequired, errors.Is(cerr, smtpclient.ErrTLS), secode, remoteIP, cerr.Error(), false
} else { } else {
return false, daneRequired, errors.Is(cerr, smtpclient.ErrTLS), "", remoteIP, err.Error(), false return false, daneRequired, errors.Is(cerr, smtpclient.ErrTLS), "", remoteIP, err.Error(), false
} }
} }
// Update (overwite) last known starttls/requiretls support for recipient domain.
func updateRecipientDomainTLS(ctx context.Context, senderAccount string, rdt store.RecipientDomainTLS) error {
acc, err := store.OpenAccount(senderAccount)
if err != nil {
return fmt.Errorf("open account: %w", err)
}
err = acc.DB.Write(ctx, func(tx *bstore.Tx) error {
// First delete any existing record.
if err := tx.Delete(&store.RecipientDomainTLS{Domain: rdt.Domain}); err != nil && err != bstore.ErrAbsent {
return fmt.Errorf("removing previous recipient domain tls status: %w", err)
}
// Insert new record.
return tx.Insert(&rdt)
})
if err != nil {
return fmt.Errorf("adding recipient domain tls status to account database: %w", err)
}
return nil
}

View file

@ -15,7 +15,7 @@ import (
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
) )
func queueDSNFailure(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string) { func deliverDSNFailure(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string) {
const subject = "mail delivery failed" const subject = "mail delivery failed"
message := fmt.Sprintf(` message := fmt.Sprintf(`
Delivery has failed permanently for your email to: Delivery has failed permanently for your email to:
@ -29,10 +29,10 @@ Error during the last delivery attempt:
%s %s
`, m.Recipient().XString(m.SMTPUTF8), errmsg) `, m.Recipient().XString(m.SMTPUTF8), errmsg)
queueDSN(log, m, remoteMTA, secodeOpt, errmsg, true, nil, subject, message) deliverDSN(log, m, remoteMTA, secodeOpt, errmsg, true, nil, subject, message)
} }
func queueDSNDelay(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, retryUntil time.Time) { func deliverDSNDelay(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, retryUntil time.Time) {
const subject = "mail delivery delayed" const subject = "mail delivery delayed"
message := fmt.Sprintf(` message := fmt.Sprintf(`
Delivery has been delayed of your email to: Delivery has been delayed of your email to:
@ -47,15 +47,14 @@ Error during the last delivery attempt:
%s %s
`, m.Recipient().XString(false), errmsg) `, m.Recipient().XString(false), errmsg)
queueDSN(log, m, remoteMTA, secodeOpt, errmsg, false, &retryUntil, subject, message) deliverDSN(log, m, remoteMTA, secodeOpt, errmsg, false, &retryUntil, subject, message)
} }
// We only queue DSNs for delivery failures for emails submitted by authenticated // We only queue DSNs for delivery failures for emails submitted by authenticated
// users. So we are delivering to local users. ../rfc/5321:1466 // users. So we are delivering to local users. ../rfc/5321:1466
// ../rfc/5321:1494 // ../rfc/5321:1494
// ../rfc/7208:490 // ../rfc/7208:490
// todo future: when we implement relaying, we should be able to send DSNs to non-local users. and possibly specify a null mailfrom. ../rfc/5321:1503 func deliverDSN(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, permanent bool, retryUntil *time.Time, subject, textBody string) {
func queueDSN(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, permanent bool, retryUntil *time.Time, subject, textBody string) {
kind := "delayed delivery" kind := "delayed delivery"
if permanent { if permanent {
kind = "failure" kind = "failure"

View file

@ -99,6 +99,21 @@ type Msg struct {
// admin interface. If empty (the default for a submitted message), regular routing // admin interface. If empty (the default for a submitted message), regular routing
// rules apply. // rules apply.
Transport string Transport string
// RequireTLS influences TLS verification during delivery.
//
// If nil, the recipient domain policy is followed (MTA-STS and/or DANE), falling
// back to optional opportunistic non-verified STARTTLS.
//
// If RequireTLS is true (through SMTP REQUIRETLS extension or webmail submit),
// MTA-STS or DANE is required, as well as REQUIRETLS support by the next hop
// server.
//
// If RequireTLS is false (through messag header "TLS-Required: No"), the recipient
// domain's policy is ignored if it does not lead to a successful TLS connection,
// i.e. falling back to SMTP delivery with unverified STARTTLS or plain text.
RequireTLS *bool
// ../rfc/8689:250
} }
// Sender of message as used in MAIL FROM. // Sender of message as used in MAIL FROM.
@ -180,7 +195,7 @@ func Count(ctx context.Context) (int, error) {
// this data is used as the message when delivering the DSN and the remote SMTP // this data is used as the message when delivering the DSN and the remote SMTP
// server supports SMTPUTF8. If the remote SMTP server does not support SMTPUTF8, // server supports SMTPUTF8. If the remote SMTP server does not support SMTPUTF8,
// the regular non-utf8 message is delivered. // the regular non-utf8 message is delivered.
func Add(ctx context.Context, log *mlog.Log, senderAccount string, mailFrom, rcptTo smtp.Path, has8bit, smtputf8 bool, size int64, messageID string, msgPrefix []byte, msgFile *os.File, dsnutf8Opt []byte) (int64, error) { func Add(ctx context.Context, log *mlog.Log, senderAccount string, mailFrom, rcptTo smtp.Path, has8bit, smtputf8 bool, size int64, messageID string, msgPrefix []byte, msgFile *os.File, dsnutf8Opt []byte, requireTLS *bool) (int64, error) {
// todo: Add should accept multiple rcptTo if they are for the same domain. so we can queue them for delivery in one (or just a few) session(s), transferring the data only once. ../rfc/5321:3759 // todo: Add should accept multiple rcptTo if they are for the same domain. so we can queue them for delivery in one (or just a few) session(s), transferring the data only once. ../rfc/5321:3759
if Localserve { if Localserve {
@ -221,7 +236,7 @@ func Add(ctx context.Context, log *mlog.Log, senderAccount string, mailFrom, rcp
}() }()
now := time.Now() now := time.Now()
qm := Msg{0, now, senderAccount, mailFrom.Localpart, mailFrom.IPDomain, rcptTo.Localpart, rcptTo.IPDomain, formatIPDomain(rcptTo.IPDomain), 0, nil, now, nil, "", has8bit, smtputf8, size, messageID, msgPrefix, dsnutf8Opt, ""} qm := Msg{0, now, senderAccount, mailFrom.Localpart, mailFrom.IPDomain, rcptTo.Localpart, rcptTo.IPDomain, formatIPDomain(rcptTo.IPDomain), 0, nil, now, nil, "", has8bit, smtputf8, size, messageID, msgPrefix, dsnutf8Opt, "", requireTLS}
if err := tx.Insert(&qm); err != nil { if err := tx.Insert(&qm); err != nil {
return 0, err return 0, err
@ -339,6 +354,18 @@ func Drop(ctx context.Context, ID int64, toDomain string, recipient string) (int
return n, nil return n, nil
} }
// SaveRequireTLS updates the RequireTLS field of the message with id.
func SaveRequireTLS(ctx context.Context, id int64, requireTLS *bool) error {
return DB.Write(ctx, func(tx *bstore.Tx) error {
m := Msg{ID: id}
if err := tx.Get(&m); err != nil {
return fmt.Errorf("get message: %w", err)
}
m.RequireTLS = requireTLS
return tx.Update(&m)
})
}
type ReadReaderAtCloser interface { type ReadReaderAtCloser interface {
io.ReadCloser io.ReadCloser
io.ReaderAt io.ReaderAt

View file

@ -14,6 +14,7 @@ import (
"net" "net"
"os" "os"
"path/filepath" "path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
@ -37,9 +38,27 @@ func tcheck(t *testing.T, err error, msg string) {
} }
} }
func tcompare(t *testing.T, got, exp any) {
t.Helper()
if !reflect.DeepEqual(got, exp) {
t.Fatalf("got %v, expected %v", got, exp)
}
}
var keepAccount bool
func setup(t *testing.T) (*store.Account, func()) { func setup(t *testing.T) (*store.Account, func()) {
// Prepare config so email can be delivered to mjl@mox.example. // Prepare config so email can be delivered to mjl@mox.example.
os.RemoveAll("../testdata/queue/data")
// Don't trigger the account consistency checks. Only remove account files on first
// (of randomized) runs.
if !keepAccount {
os.RemoveAll("../testdata/queue/data")
keepAccount = true
} else {
os.RemoveAll("../testdata/queue/data/queue")
}
mox.Context = ctxbg mox.Context = ctxbg
mox.ConfigStaticPath = filepath.FromSlash("../testdata/queue/mox.conf") mox.ConfigStaticPath = filepath.FromSlash("../testdata/queue/mox.conf")
mox.MustLoadConfig(true, false) mox.MustLoadConfig(true, false)
@ -91,10 +110,10 @@ func TestQueue(t *testing.T) {
defer os.Remove(mf.Name()) defer os.Remove(mf.Name())
defer mf.Close() defer mf.Close()
_, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
_, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
msgs, err = List(ctxbg) msgs, err = List(ctxbg)
@ -198,6 +217,10 @@ func TestQueue(t *testing.T) {
smtpdone := make(chan struct{}) smtpdone := make(chan struct{})
fakeSMTPServer := func(server net.Conn) { fakeSMTPServer := func(server net.Conn) {
defer func() {
smtpdone <- struct{}{}
}()
// We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies. // We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies.
fmt.Fprintf(server, "220 mox.example\r\n") fmt.Fprintf(server, "220 mox.example\r\n")
br := bufio.NewReader(server) br := bufio.NewReader(server)
@ -225,14 +248,16 @@ func TestQueue(t *testing.T) {
writeline("250 ok") writeline("250 ok")
readline("quit") readline("quit")
writeline("221 ok") writeline("221 ok")
smtpdone <- struct{}{}
} }
goodTLSConfig := tls.Config{Certificates: []tls.Certificate{moxCert}} goodTLSConfig := tls.Config{Certificates: []tls.Certificate{moxCert}}
makeFakeSMTPSTARTTLSServer := func(tlsConfig *tls.Config, nstarttls int) func(server net.Conn) { makeFakeSMTPSTARTTLSServer := func(tlsConfig *tls.Config, nstarttls int, requiretls bool) func(server net.Conn) {
attempt := 0 attempt := 0
return func(server net.Conn) { return func(server net.Conn) {
defer func() {
smtpdone <- struct{}{}
}()
attempt++ attempt++
// We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies. // We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies.
@ -264,7 +289,12 @@ func TestQueue(t *testing.T) {
br = bufio.NewReader(server) br = bufio.NewReader(server)
readline("ehlo") readline("ehlo")
writeline("250 mox.example") if requiretls {
writeline("250-mox.example")
writeline("250 requiretls")
} else {
writeline("250 mox.example")
}
} }
readline("mail") readline("mail")
writeline("250 ok") writeline("250 ok")
@ -277,17 +307,19 @@ func TestQueue(t *testing.T) {
writeline("250 ok") writeline("250 ok")
readline("quit") readline("quit")
writeline("221 ok") writeline("221 ok")
smtpdone <- struct{}{}
} }
} }
fakeSMTPSTARTTLSServer := makeFakeSMTPSTARTTLSServer(&goodTLSConfig, 0) fakeSMTPSTARTTLSServer := makeFakeSMTPSTARTTLSServer(&goodTLSConfig, 0, true)
makeBadFakeSMTPSTARTTLSServer := func() func(server net.Conn) { makeBadFakeSMTPSTARTTLSServer := func(requiretls bool) func(server net.Conn) {
return makeFakeSMTPSTARTTLSServer(&tls.Config{MaxVersion: tls.VersionTLS10, Certificates: []tls.Certificate{moxCert}}, 1) return makeFakeSMTPSTARTTLSServer(&tls.Config{MaxVersion: tls.VersionTLS10, Certificates: []tls.Certificate{moxCert}}, 1, requiretls)
} }
fakeSubmitServer := func(server net.Conn) { fakeSubmitServer := func(server net.Conn) {
defer func() {
smtpdone <- struct{}{}
}()
// We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies. // We do a minimal fake smtp server. We cannot import smtpserver.Serve due to cyclic dependencies.
fmt.Fprintf(server, "220 mox.example\r\n") fmt.Fprintf(server, "220 mox.example\r\n")
br := bufio.NewReader(server) br := bufio.NewReader(server)
@ -307,11 +339,9 @@ func TestQueue(t *testing.T) {
fmt.Fprintf(server, "250 ok\r\n") fmt.Fprintf(server, "250 ok\r\n")
br.ReadString('\n') // Should be QUIT. br.ReadString('\n') // Should be QUIT.
fmt.Fprintf(server, "221 ok\r\n") fmt.Fprintf(server, "221 ok\r\n")
smtpdone <- struct{}{}
} }
testDeliver := func(fakeServer func(conn net.Conn)) bool { testQueue := func(expectDSN bool, fakeServer func(conn net.Conn)) bool {
t.Helper() t.Helper()
var pipes []net.Conn var pipes []net.Conn
@ -346,6 +376,12 @@ func TestQueue(t *testing.T) {
smtpclient.DialHook = nil smtpclient.DialHook = nil
}() }()
inbox, err := bstore.QueryDB[store.Mailbox](ctxbg, acc.DB).FilterNonzero(store.Mailbox{Name: "Inbox"}).Get()
tcheck(t, err, "get inbox")
inboxCount, err := bstore.QueryDB[store.Message](ctxbg, acc.DB).FilterNonzero(store.Message{MailboxID: inbox.ID}).Count()
tcheck(t, err, "querying messages in inbox")
waitDeliver := func() { waitDeliver := func() {
t.Helper() t.Helper()
timer.Reset(time.Second) timer.Reset(time.Second)
@ -358,6 +394,14 @@ func TestQueue(t *testing.T) {
xmsgs, err := List(ctxbg) xmsgs, err := List(ctxbg)
tcheck(t, err, "list queue") tcheck(t, err, "list queue")
if len(xmsgs) == 0 { if len(xmsgs) == 0 {
ninbox, err := bstore.QueryDB[store.Message](ctxbg, acc.DB).FilterNonzero(store.Message{MailboxID: inbox.ID}).Count()
tcheck(t, err, "querying messages in inbox")
if expectDSN && ninbox != inboxCount+1 {
t.Fatalf("got %d messages in inbox, previously %d, expected 1 additional for dsn", ninbox, inboxCount)
} else if !expectDSN && ninbox != inboxCount {
t.Fatalf("got %d messages in inbox, previously %d, expected no additional messages", ninbox, inboxCount)
}
break break
} }
i++ i++
@ -379,6 +423,14 @@ func TestQueue(t *testing.T) {
waitDeliver() waitDeliver()
return wasNetDialer return wasNetDialer
} }
testDeliver := func(fakeServer func(conn net.Conn)) bool {
t.Helper()
return testQueue(false, fakeServer)
}
testDSN := func(fakeServer func(conn net.Conn)) bool {
t.Helper()
return testQueue(true, fakeServer)
}
// Test direct delivery. // Test direct delivery.
wasNetDialer := testDeliver(fakeSMTPServer) wasNetDialer := testDeliver(fakeSMTPServer)
@ -388,7 +440,7 @@ func TestQueue(t *testing.T) {
// Add a message to be delivered with submit because of its route. // Add a message to be delivered with submit because of its route.
topath := smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "submit.example"}}} topath := smtp.Path{Localpart: "mjl", IPDomain: dns.IPDomain{Domain: dns.Domain{ASCII: "submit.example"}}}
_, err = Add(ctxbg, xlog, "mjl", path, topath, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) _, err = Add(ctxbg, xlog, "mjl", path, topath, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
wasNetDialer = testDeliver(fakeSubmitServer) wasNetDialer = testDeliver(fakeSubmitServer)
if !wasNetDialer { if !wasNetDialer {
@ -396,7 +448,7 @@ func TestQueue(t *testing.T) {
} }
// Add a message to be delivered with submit because of explicitly configured transport, that uses TLS. // Add a message to be delivered with submit because of explicitly configured transport, that uses TLS.
msgID, err := Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) msgID, err := Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
transportSubmitTLS := "submittls" transportSubmitTLS := "submittls"
n, err = Kick(ctxbg, msgID, "", "", &transportSubmitTLS) n, err = Kick(ctxbg, msgID, "", "", &transportSubmitTLS)
@ -420,7 +472,7 @@ func TestQueue(t *testing.T) {
} }
// Add a message to be delivered with socks. // Add a message to be delivered with socks.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<socks@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<socks@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
transportSocks := "socks" transportSocks := "socks"
n, err = Kick(ctxbg, msgID, "", "", &transportSocks) n, err = Kick(ctxbg, msgID, "", "", &transportSocks)
@ -434,7 +486,7 @@ func TestQueue(t *testing.T) {
} }
// Add message to be delivered with opportunistic TLS verification. // Add message to be delivered with opportunistic TLS verification.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil) n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
@ -444,14 +496,14 @@ func TestQueue(t *testing.T) {
testDeliver(fakeSMTPSTARTTLSServer) testDeliver(fakeSMTPSTARTTLSServer)
// Test fallback to plain text with TLS handshake fails. // Test fallback to plain text with TLS handshake fails.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<badtls@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<badtls@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil) n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
if n != 1 { if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n) t.Fatalf("kick changed %d messages, expected 1", n)
} }
testDeliver(makeBadFakeSMTPSTARTTLSServer()) testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
// Add message to be delivered with DANE verification. // Add message to be delivered with DANE verification.
resolver.AllAuthentic = true resolver.AllAuthentic = true
@ -460,7 +512,25 @@ func TestQueue(t *testing.T) {
{Usage: adns.TLSAUsageDANEEE, Selector: adns.TLSASelectorSPKI, MatchType: adns.TLSAMatchTypeFull, CertAssoc: moxCert.Leaf.RawSubjectPublicKeyInfo}, {Usage: adns.TLSAUsageDANEEE, Selector: adns.TLSASelectorSPKI, MatchType: adns.TLSAMatchTypeFull, CertAssoc: moxCert.Leaf.RawSubjectPublicKeyInfo},
}, },
} }
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<dane@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<dane@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer)
// We should know starttls/requiretls by now.
rdt := store.RecipientDomainTLS{Domain: "mox.example"}
err = acc.DB.Get(ctxbg, &rdt)
tcheck(t, err, "get recipientdomaintls")
tcompare(t, rdt.STARTTLS, true)
tcompare(t, rdt.RequireTLS, true)
// Add message to be delivered with verified TLS and REQUIRETLS.
yes := true
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<opportunistictls@localhost>", nil, mf, nil, &yes)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil) n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
@ -475,7 +545,7 @@ func TestQueue(t *testing.T) {
{}, {},
}, },
} }
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<daneunusable@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<daneunusable@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil) n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
@ -492,22 +562,74 @@ func TestQueue(t *testing.T) {
{Usage: adns.TLSAUsageDANEEE, Selector: adns.TLSASelectorSPKI, MatchType: adns.TLSAMatchTypeFull, CertAssoc: make([]byte, sha256.Size)}, {Usage: adns.TLSAUsageDANEEE, Selector: adns.TLSASelectorSPKI, MatchType: adns.TLSAMatchTypeFull, CertAssoc: make([]byte, sha256.Size)},
}, },
} }
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<daneinsecure@localhost>", nil, mf, nil) msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<daneinsecure@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil) n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue") tcheck(t, err, "kick queue")
if n != 1 { if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n) t.Fatalf("kick changed %d messages, expected 1", n)
} }
testDeliver(makeBadFakeSMTPSTARTTLSServer()) testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
resolver.Inauthentic = nil resolver.Inauthentic = nil
// STARTTLS failed, so not known supported.
rdt = store.RecipientDomainTLS{Domain: "mox.example"}
err = acc.DB.Get(ctxbg, &rdt)
tcheck(t, err, "get recipientdomaintls")
tcompare(t, rdt.STARTTLS, false)
tcompare(t, rdt.RequireTLS, false)
// Check that message is delivered with TLS-Required: No and non-matching DANE record.
no := false
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<tlsrequirednostarttls@localhost>", nil, mf, nil, &no)
tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(fakeSMTPSTARTTLSServer)
// Check that message is delivered with TLS-Required: No and bad TLS, falling back to plain text.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<tlsrequirednoplaintext@localhost>", nil, mf, nil, &no)
tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDeliver(makeBadFakeSMTPSTARTTLSServer(true))
// Add message with requiretls that fails immediately due to no REQUIRETLS support in all servers.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<tlsrequiredunsupported@localhost>", nil, mf, nil, &yes)
tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
testDSN(makeBadFakeSMTPSTARTTLSServer(false))
// Restore pre-DANE behaviour. // Restore pre-DANE behaviour.
resolver.AllAuthentic = false resolver.AllAuthentic = false
resolver.TLSA = nil resolver.TLSA = nil
// Add message with requiretls that fails immediately due to no verification policy for recipient domain.
msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<tlsrequirednopolicy@localhost>", nil, mf, nil, &yes)
tcheck(t, err, "add message to queue for delivery")
n, err = Kick(ctxbg, msgID, "", "", nil)
tcheck(t, err, "kick queue")
if n != 1 {
t.Fatalf("kick changed %d messages, expected 1", n)
}
// Based on DNS lookups, there won't be any dialing or SMTP connection.
dialed <- struct{}{}
testDSN(func(conn net.Conn) {
smtpdone <- struct{}{}
})
// Add another message that we'll fail to deliver entirely. // Add another message that we'll fail to deliver entirely.
_, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
msgs, err = List(ctxbg) msgs, err = List(ctxbg)
@ -666,7 +788,7 @@ func TestQueueStart(t *testing.T) {
mf := prepareFile(t) mf := prepareFile(t)
defer os.Remove(mf.Name()) defer os.Remove(mf.Name())
defer mf.Close() defer mf.Close()
_, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil) _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "<test@localhost>", nil, mf, nil, nil)
tcheck(t, err, "add message to queue for delivery") tcheck(t, err, "add message to queue for delivery")
checkDialed(true) checkDialed(true)

View file

@ -16,6 +16,7 @@ import (
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/sasl" "github.com/mjl-/mox/sasl"
"github.com/mjl-/mox/smtp"
"github.com/mjl-/mox/smtpclient" "github.com/mjl-/mox/smtpclient"
"github.com/mjl-/mox/store" "github.com/mjl-/mox/store"
) )
@ -51,11 +52,20 @@ func deliverSubmit(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
qlog.Debug("queue deliversubmit result", mlog.Field("host", transport.DNSHost), mlog.Field("port", port), mlog.Field("attempt", m.Attempts), mlog.Field("permanent", permanent), mlog.Field("secodeopt", secodeOpt), mlog.Field("errmsg", errmsg), mlog.Field("ok", success), mlog.Field("duration", time.Since(start))) qlog.Debug("queue deliversubmit result", mlog.Field("host", transport.DNSHost), mlog.Field("port", port), mlog.Field("attempt", m.Attempts), mlog.Field("permanent", permanent), mlog.Field("secodeopt", secodeOpt), mlog.Field("errmsg", errmsg), mlog.Field("ok", success), mlog.Field("duration", time.Since(start)))
}() }()
// We don't have to attempt SMTP-DANE for submission, since it only applies to SMTP // todo: SMTP-DANE should be used when relaying on port 25.
// relaying on port 25. ../rfc/7672:1261 // ../rfc/7672:1261
// todo: for submission, understand SRV records, and even DANE. // todo: for submission, understand SRV records, and even DANE.
// If submit was done with REQUIRETLS extension for SMTP, we must verify TLS
// certificates. If our submission connection is not configured that way, abort.
requireTLS := m.RequireTLS != nil && *m.RequireTLS
if requireTLS && tlsMode != smtpclient.TLSStrictStartTLS && tlsMode != smtpclient.TLSStrictImmediate {
errmsg = fmt.Sprintf("transport %s: message requires verified tls but transport does not verify tls", transportName)
fail(qlog, m, backoff, true, dsn.NameIP{}, smtp.SePol7MissingReqTLS, errmsg)
return
}
dialctx, dialcancel := context.WithTimeout(context.Background(), 30*time.Second) dialctx, dialcancel := context.WithTimeout(context.Background(), 30*time.Second)
defer dialcancel() defer dialcancel()
if m.DialedIPs == nil { if m.DialedIPs == nil {
@ -165,7 +175,7 @@ func deliverSubmit(cid int64, qlog *mlog.Log, resolver dns.Resolver, dialer smtp
deliverctx, delivercancel := context.WithTimeout(context.Background(), time.Duration(60+size/(1024*1024))*time.Second) deliverctx, delivercancel := context.WithTimeout(context.Background(), time.Duration(60+size/(1024*1024))*time.Second)
defer delivercancel() defer delivercancel()
err = client.Deliver(deliverctx, m.Sender().String(), m.Recipient().String(), size, msgr, req8bit, reqsmtputf8) err = client.Deliver(deliverctx, m.Sender().String(), m.Recipient().String(), size, msgr, req8bit, reqsmtputf8, requireTLS)
if err != nil { if err != nil {
qlog.Infox("delivery failed", err) qlog.Infox("delivery failed", err)
} }

View file

@ -3,6 +3,7 @@ package main
import ( import (
"bufio" "bufio"
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"log" "log"
@ -23,18 +24,27 @@ import (
) )
var submitconf struct { var submitconf struct {
LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."` LocalHostname string `sconf-doc:"Hosts don't always have an FQDN, set it explicitly, for EHLO."`
Host string `sconf-doc:"Host to dial for delivery, e.g. mail.<domain>."` Host string `sconf-doc:"Host to dial for delivery, e.g. mail.<domain>."`
Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."` Port int `sconf-doc:"Port to dial for delivery, e.g. 465 for submissions, 587 for submission, or perhaps 25 for smtp."`
TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."` TLS bool `sconf-doc:"Connect with TLS. Usually for connections to port 465."`
STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."` STARTTLS bool `sconf-doc:"After starting in plain text, use STARTTLS to enable TLS. For port 587 and 25."`
Username string `sconf-doc:"For SMTP authentication."` Username string `sconf-doc:"For SMTP authentication."`
Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5, PLAIN."` Password string `sconf-doc:"For password-based SMTP authentication, e.g. SCRAM-SHA-256, SCRAM-SHA-1, CRAM-MD5, PLAIN."`
AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256. If not set, any mutually supported algorithm can be used, in order of most to least secure."` AuthMethod string `sconf-doc:"If set, only attempt this authentication mechanism. E.g. SCRAM-SHA-256. If not set, any mutually supported algorithm can be used, in order of most to least secure."`
From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."` From string `sconf-doc:"Address for MAIL FROM in SMTP and From-header in message."`
DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."` DefaultDestination string `sconf:"optional" sconf-doc:"Used when specified address does not contain an @ and may be a local user (eg root)."`
RequireTLS RequireTLSOption `sconf:"optional" sconf-doc:"If yes, submission server must implement SMTP REQUIRETLS extension, and connection to submission server must use verified TLS. If no, a TLS-Required header with value no is added to the message, allowing fallback to unverified TLS or plain text delivery despite recpient domain policies. By default, the submission server will follow the policies of the recipient domain (MTA-STS and/or DANE), and apply unverified opportunistic TLS with STARTTLS."`
} }
type RequireTLSOption string
const (
RequireTLSDefault RequireTLSOption = ""
RequireTLSYes RequireTLSOption = "yes"
RequireTLSNo RequireTLSOption = "no"
)
func cmdConfigDescribeSendmail(c *cmd) { func cmdConfigDescribeSendmail(c *cmd) {
c.params = ">/etc/moxsubmit.conf" c.params = ">/etc/moxsubmit.conf"
c.help = `Describe configuration for mox when invoked as sendmail.` c.help = `Describe configuration for mox when invoked as sendmail.`
@ -157,6 +167,9 @@ binary should be setgid that group:
if !haveTo { if !haveTo {
line = fmt.Sprintf("To: <%s>\r\n", recipient) + line line = fmt.Sprintf("To: <%s>\r\n", recipient) + line
} }
if submitconf.RequireTLS == RequireTLSNo {
line = "TLS-Required: No\r\n" + line
}
header = false header = false
} else if header { } else if header {
t := strings.SplitN(line, ":", 2) t := strings.SplitN(line, ":", 2)
@ -199,6 +212,9 @@ binary should be setgid that group:
break break
} }
} }
if header && submitconf.RequireTLS == RequireTLSNo {
sb.WriteString("TLS-Required: No\r\n")
}
msg := sb.String() msg := sb.String()
if recipient == "" { if recipient == "" {
@ -262,6 +278,8 @@ binary should be setgid that group:
tlsMode = smtpclient.TLSStrictImmediate tlsMode = smtpclient.TLSStrictImmediate
} else if submitconf.STARTTLS { } else if submitconf.STARTTLS {
tlsMode = smtpclient.TLSStrictStartTLS tlsMode = smtpclient.TLSStrictStartTLS
} else if submitconf.RequireTLS == RequireTLSYes {
xsavecheckf(errors.New("cannot submit with requiretls enabled without tls to submission server"), "checking tls configuration")
} }
ourHostname, err := dns.ParseDomain(submitconf.LocalHostname) ourHostname, err := dns.ParseDomain(submitconf.LocalHostname)
@ -277,7 +295,7 @@ binary should be setgid that group:
client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, ourHostname, remoteHostname, auth, nil, nil, nil) client, err := smtpclient.New(ctx, mlog.New("sendmail"), conn, tlsMode, ourHostname, remoteHostname, auth, nil, nil, nil)
xsavecheckf(err, "open smtp session") xsavecheckf(err, "open smtp session")
err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false) err = client.Deliver(ctx, submitconf.From, recipient, int64(len(msg)), strings.NewReader(msg), true, false, submitconf.RequireTLS == RequireTLSYes)
xsavecheckf(err, "submit message") xsavecheckf(err, "submit message")
if err := client.Close(); err != nil { if err := client.Close(); err != nil {

View file

@ -44,17 +44,27 @@ var (
"secode", "secode",
}, },
) )
metricTLSRequiredNoIgnored = promauto.NewCounterVec(
prometheus.CounterOpts{
Name: "mox_smtpclient_tlsrequiredno_ignored_total",
Help: "Connection attempts with TLS policy findings ignored due to message with TLS-Required: No header. Does not cover case where TLS certificate cannot be PKIX-verified.",
},
[]string{
"ignored", // daneverification (no matching tlsa record)
},
)
) )
var ( 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. 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") 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") ErrSMTPUTF8Unsupported = errors.New("remote smtp server does not implement smtputf8 extension, required by message")
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. ErrRequireTLSUnsupported = errors.New("remote smtp server does not implement requiretls extension, required for delivery")
ErrProtocol = errors.New("smtp protocol error") // After a malformed SMTP response or inconsistent multi-line response. 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.
ErrTLS = errors.New("tls error") // E.g. handshake failure, or hostname verification was required and failed. ErrProtocol = errors.New("smtp protocol error") // After a malformed SMTP response or inconsistent multi-line response.
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. ErrTLS = errors.New("tls error") // E.g. handshake failure, or hostname verification was required and failed.
ErrClosed = errors.New("client is closed") 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. // TLSMode indicates if TLS must, should or must not be used.
@ -67,7 +77,8 @@ const (
// Required TLS with STARTTLS for SMTP servers, without verifiying the certificate. // Required TLS with STARTTLS for SMTP servers, without verifiying the certificate.
// This mode is needed to fallback after only unusable DANE records were found // This mode is needed to fallback after only unusable DANE records were found
// (e.g. with unknown parameters in the TLSA records). // (e.g. with unknown parameters in the TLSA records). Also for allowing
// verification errors with DANE with message header TLS-Required no.
TLSUnverifiedStartTLS TLSMode = "unverifiedstarttls" TLSUnverifiedStartTLS TLSMode = "unverifiedstarttls"
// TLS immediately ("implicit TLS"), with either verified DANE TLSA records or a // TLS immediately ("implicit TLS"), with either verified DANE TLSA records or a
@ -105,6 +116,7 @@ type Client struct {
lastlog time.Time // For adding delta timestamps between log lines. lastlog time.Time // For adding delta timestamps between log lines.
cmds []string // Last or active command, for generating errors and metrics. cmds []string // Last or active command, for generating errors and metrics.
cmdStart time.Time // Start of command. cmdStart time.Time // Start of command.
tls bool // Whether connection is TLS protected.
botched bool // If set, protocol is out of sync and no further commands can be sent. 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. needRset bool // If set, a new delivery requires an RSET command.
@ -117,6 +129,7 @@ type Client struct {
extPipelining bool // Remote server supports command pipelining. extPipelining bool // Remote server supports command pipelining.
extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension. extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension.
extAuthMechanisms []string // Supported authentication mechanisms. extAuthMechanisms []string // Supported authentication mechanisms.
extRequireTLS bool // Remote supports REQUIRETLS extension.
} }
// Error represents a failure to deliver a message. // Error represents a failure to deliver a message.
@ -185,7 +198,9 @@ func (e Error) Error() string {
// verification. By default, SMTP does not verify TLS for interopability reasons, // verification. 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 // 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) // error is encountered, the caller may want to try again (on a new connection)
// without TLS. // without TLS. For messages with header TLS-Required no, DANE records may be
// passed along with tlsMode TLSUnverifiedStartTLS. In that case, failing DANE
// verification causes an error to be logged, but the connection won't be aborted.
// //
// If auth is non-empty, authentication will be done with the first algorithm // 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 // supported by the server. If none of the algorithms are supported, an error is
@ -212,13 +227,14 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, ehl
if tlsMode == TLSStrictImmediate { if tlsMode == TLSStrictImmediate {
// todo: we could also verify DANE here. not applicable to SMTP delivery. // todo: we could also verify DANE here. not applicable to SMTP delivery.
config := c.tlsConfig(tlsMode) config := c.tlsConfig(tlsMode)
tlsconn := tls.Client(conn, &config) tlsconn := tls.Client(conn, config)
if err := tlsconn.HandshakeContext(ctx); err != nil { if err := tlsconn.HandshakeContext(ctx); err != nil {
return nil, err return nil, err
} }
c.conn = tlsconn c.conn = tlsconn
tlsversion, ciphersuite := mox.TLSInfo(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)) c.log.Debug("tls client handshake done", mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", remoteHostname))
c.tls = true
} else { } else {
c.conn = conn c.conn = conn
} }
@ -239,12 +255,26 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, ehl
return c, nil return c, nil
} }
func (c *Client) tlsConfig(tlsMode TLSMode) tls.Config { func (c *Client) tlsConfig(tlsMode TLSMode) *tls.Config {
if c.daneRecords != nil { if c.daneRecords != nil {
return dane.TLSClientConfig(c.log, c.daneRecords, c.remoteHostname, c.moreRemoteHostnames, c.verifiedRecord) config := dane.TLSClientConfig(c.log, c.daneRecords, c.remoteHostname, c.moreRemoteHostnames, c.verifiedRecord)
if tlsMode == TLSUnverifiedStartTLS {
// In case of delivery with header "TLS-Required: No", the connection should not be
// aborted.
origVerify := config.VerifyConnection
config.VerifyConnection = func(cs tls.ConnectionState) error {
err := origVerify(cs)
if err != nil {
c.log.Infox("verifying dane failed, continuing due to tls mode unverified starttls, due to tls-required-no message header", err)
metricTLSRequiredNoIgnored.WithLabelValues("daneverification").Inc()
}
return nil
}
}
return &config
} }
// todo: possibly accept older TLS versions for TLSOpportunistic? // todo: possibly accept older TLS versions for TLSOpportunistic?
return tls.Config{ return &tls.Config{
ServerName: c.remoteHostname.ASCII, ServerName: c.remoteHostname.ASCII,
RootCAs: mox.Conf.Static.TLS.CertPool, RootCAs: mox.Conf.Static.TLS.CertPool,
InsecureSkipVerify: tlsMode == TLSOpportunistic || tlsMode == TLSUnverifiedStartTLS, InsecureSkipVerify: tlsMode == TLSOpportunistic || tlsMode == TLSUnverifiedStartTLS,
@ -538,6 +568,8 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
c.ext8bitmime = true c.ext8bitmime = true
case "PIPELINING": case "PIPELINING":
c.extPipelining = true c.extPipelining = true
case "REQUIRETLS":
c.extRequireTLS = true
default: default:
// For SMTPUTF8 we must ignore any parameter. ../rfc/6531:207 // For SMTPUTF8 we must ignore any parameter. ../rfc/6531:207
if s == "SMTPUTF8" || strings.HasPrefix(s, "SMTPUTF8 ") { if s == "SMTPUTF8" || strings.HasPrefix(s, "SMTPUTF8 ") {
@ -592,7 +624,7 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
// For TLSStrictStartTLS, 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 // ../rfc/8461:646
tlsConfig := c.tlsConfig(tlsMode) tlsConfig := c.tlsConfig(tlsMode)
nconn := tls.Client(conn, &tlsConfig) nconn := tls.Client(conn, tlsConfig)
c.conn = nconn c.conn = nconn
nctx, cancel := context.WithTimeout(ctx, time.Minute) nctx, cancel := context.WithTimeout(ctx, time.Minute)
@ -609,6 +641,7 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
tlsversion, ciphersuite := mox.TLSInfo(nconn) tlsversion, ciphersuite := mox.TLSInfo(nconn)
c.log.Debug("starttls client handshake done", mlog.Field("tlsmode", tlsMode), mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", c.remoteHostname), mlog.Field("danerecord", c.verifiedRecord)) c.log.Debug("starttls client handshake done", mlog.Field("tlsmode", tlsMode), mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", c.remoteHostname), mlog.Field("danerecord", c.verifiedRecord))
c.tls = true
hello(false) hello(false)
} }
@ -720,6 +753,24 @@ func (c *Client) SupportsSMTPUTF8() bool {
return c.extSMTPUTF8 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
}
// TLSEnabled returns whether TLS is enabled for this connection.
func (c *Client) TLSEnabled() bool {
return c.tls
}
// Deliver attempts to deliver a message to a mail server. // 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 // mailFrom must be an email address, or empty in case of a DSN. rcptTo must be
@ -733,12 +784,15 @@ func (c *Client) SupportsSMTPUTF8() bool {
// character, or when UTF-8 is used in a localpart, reqSMTPUTF8 must be true. If set, // 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. // 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: // Deliver uses the following SMTP extensions if the remote server supports them:
// 8BITMIME, SMTPUTF8, SIZE, PIPELINING, ENHANCEDSTATUSCODES, STARTTLS. // 8BITMIME, SMTPUTF8, SIZE, PIPELINING, ENHANCEDSTATUSCODES, STARTTLS.
// //
// Returned errors can be of type Error, one of the Err-variables in this package // 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. // 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 bool) (rerr error) { 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) defer c.recover(&rerr)
if c.origConn == nil { if c.origConn == nil {
@ -761,6 +815,9 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms
// ../rfc/6531:313 // ../rfc/6531:313
c.xerrorf(false, 0, "", "", "%w", ErrSMTPUTF8Unsupported) c.xerrorf(false, 0, "", "", "%w", ErrSMTPUTF8Unsupported)
} }
if !c.extRequireTLS && requireTLS {
c.xerrorf(false, 0, "", "", "%w", ErrRequireTLSUnsupported)
}
if c.extSize && msgSize > c.maxSize { 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) c.xerrorf(true, 0, "", "", "%w: message is %d bytes, remote has a %d bytes maximum size", ErrSize, msgSize, c.maxSize)
@ -782,12 +839,17 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms
// ../rfc/6531:213 // ../rfc/6531:213
smtputf8Arg = " SMTPUTF8" smtputf8Arg = " SMTPUTF8"
} }
var requiretlsArg string
if requireTLS {
// ../rfc/8689:155
requiretlsArg = " REQUIRETLS"
}
// Transaction overview: ../rfc/5321:1015 // Transaction overview: ../rfc/5321:1015
// MAIL FROM: ../rfc/5321:1879 // MAIL FROM: ../rfc/5321:1879
// RCPT TO: ../rfc/5321:1916 // RCPT TO: ../rfc/5321:1916
// DATA: ../rfc/5321:1992 // DATA: ../rfc/5321:1992
lineMailFrom := fmt.Sprintf("MAIL FROM:<%s>%s%s%s", mailFrom, mailSize, bodyType, smtputf8Arg) lineMailFrom := fmt.Sprintf("MAIL FROM:<%s>%s%s%s%s", mailFrom, mailSize, bodyType, smtputf8Arg, requiretlsArg)
lineRcptTo := fmt.Sprintf("RCPT TO:<%s>", rcptTo) lineRcptTo := fmt.Sprintf("RCPT TO:<%s>", rcptTo)
// We are going into a transaction. We'll clear this when done. // We are going into a transaction. We'll clear this when done.

View file

@ -36,6 +36,8 @@ func TestClient(t *testing.T) {
ctx := context.Background() ctx := context.Background()
log := mlog.New("smtpclient") log := mlog.New("smtpclient")
mlog.SetConfig(map[string]mlog.Level{"": mlog.LevelTrace})
type options struct { type options struct {
pipelining bool pipelining bool
ecodes bool ecodes bool
@ -43,13 +45,15 @@ func TestClient(t *testing.T) {
starttls bool starttls bool
eightbitmime bool eightbitmime bool
smtputf8 bool smtputf8 bool
requiretls bool
ehlo bool ehlo bool
tlsMode TLSMode tlsMode TLSMode
tlsHostname dns.Domain tlsHostname dns.Domain
need8bitmime bool need8bitmime bool
needsmtputf8 bool needsmtputf8 bool
auths []string // Allowed mechanisms. needsrequiretls bool
auths []string // Allowed mechanisms.
nodeliver bool // For server, whether client will attempt a delivery. nodeliver bool // For server, whether client will attempt a delivery.
} }
@ -146,6 +150,9 @@ func TestClient(t *testing.T) {
if opts.smtputf8 { if opts.smtputf8 {
writeline("250-SMTPUTF8") writeline("250-SMTPUTF8")
} }
if opts.requiretls && haveTLS {
writeline("250-REQUIRETLS")
}
if opts.auths != nil { if opts.auths != nil {
writeline("250-AUTH " + strings.Join(opts.auths, " ")) writeline("250-AUTH " + strings.Join(opts.auths, " "))
} }
@ -260,6 +267,7 @@ func TestClient(t *testing.T) {
result <- nil result <- nil
}() }()
// todo: should abort tests more properly. on client failures, we may be left with hanging test.
go func() { go func() {
defer func() { defer func() {
x := recover() x := recover()
@ -268,7 +276,8 @@ func TestClient(t *testing.T) {
} }
}() }()
fail := func(format string, args ...any) { fail := func(format string, args ...any) {
result <- fmt.Errorf("client: %w", fmt.Errorf(format, args...)) err := fmt.Errorf("client: %w", fmt.Errorf(format, args...))
result <- err
panic("stop") panic("stop")
} }
c, err := New(ctx, log, clientConn, opts.tlsMode, localhost, opts.tlsHostname, auths, nil, nil, nil) c, err := New(ctx, log, clientConn, opts.tlsMode, localhost, opts.tlsHostname, auths, nil, nil, nil)
@ -279,7 +288,7 @@ func TestClient(t *testing.T) {
result <- nil result <- nil
return return
} }
err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8) err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) { if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
fail("first deliver: got err %v, expected %v", err, expDeliverErr) fail("first deliver: got err %v, expected %v", err, expDeliverErr)
} }
@ -288,7 +297,7 @@ func TestClient(t *testing.T) {
if err != nil { if err != nil {
fail("reset: %v", err) fail("reset: %v", err)
} }
err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8) err = c.Deliver(ctx, "postmaster@mox.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), opts.need8bitmime, opts.needsmtputf8, opts.needsrequiretls)
if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) { if (err == nil) != (expDeliverErr == nil) || err != nil && !errors.Is(err, expDeliverErr) {
fail("second deliver: got err %v, expected %v", err, expDeliverErr) fail("second deliver: got err %v, expected %v", err, expDeliverErr)
} }
@ -327,11 +336,13 @@ test
smtputf8: true, smtputf8: true,
starttls: true, starttls: true,
ehlo: true, ehlo: true,
requiretls: true,
tlsMode: TLSStrictStartTLS, tlsMode: TLSStrictStartTLS,
tlsHostname: dns.Domain{ASCII: "mox.example"}, tlsHostname: dns.Domain{ASCII: "mox.example"},
need8bitmime: true, need8bitmime: true,
needsmtputf8: true, needsmtputf8: true,
needsrequiretls: true,
} }
test(msg, options{}, nil, nil, nil, nil) test(msg, options{}, nil, nil, nil, nil)
@ -346,6 +357,7 @@ test
test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-1"}}, []sasl.Client{sasl.NewClientSCRAMSHA1("test", "test")}, nil, nil, nil) test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-1"}}, []sasl.Client{sasl.NewClientSCRAMSHA1("test", "test")}, nil, nil, nil)
test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-256"}}, []sasl.Client{sasl.NewClientSCRAMSHA256("test", "test")}, nil, nil, nil) test(msg, options{ehlo: true, auths: []string{"SCRAM-SHA-256"}}, []sasl.Client{sasl.NewClientSCRAMSHA256("test", "test")}, nil, nil, nil)
// todo: add tests for failing authentication, also at various stages in SCRAM // todo: add tests for failing authentication, also at various stages in SCRAM
test(msg, options{ehlo: true, requiretls: false, needsrequiretls: true, nodeliver: true}, nil, nil, ErrRequireTLSUnsupported, nil)
// Set an expired certificate. For non-strict TLS, we should still accept it. // Set an expired certificate. For non-strict TLS, we should still accept it.
// ../rfc/7435:424 // ../rfc/7435:424
@ -441,7 +453,7 @@ func TestErrors(t *testing.T) {
panic(err) panic(err)
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
@ -461,7 +473,7 @@ func TestErrors(t *testing.T) {
panic(err) panic(err)
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
@ -483,7 +495,7 @@ func TestErrors(t *testing.T) {
panic(err) panic(err)
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err))
@ -507,7 +519,7 @@ func TestErrors(t *testing.T) {
panic(err) panic(err)
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
@ -543,7 +555,7 @@ func TestErrors(t *testing.T) {
panic(err) panic(err)
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
@ -574,14 +586,14 @@ func TestErrors(t *testing.T) {
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err))
} }
// Another delivery. // Another delivery.
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))
} }
@ -604,7 +616,7 @@ func TestErrors(t *testing.T) {
} }
msg := "" msg := ""
err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false) err = c.Deliver(ctx, "postmaster@other.example", "mjl@mox.example", int64(len(msg)), strings.NewReader(msg), false, false, false)
var xerr Error var xerr Error
if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent {
panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err))

View file

@ -13,7 +13,7 @@ import (
) )
// compose dsn message and add it to the queue for delivery to rcptTo. // compose dsn message and add it to the queue for delivery to rcptTo.
func queueDSN(ctx context.Context, c *conn, rcptTo smtp.Path, m dsn.Message) error { func queueDSN(ctx context.Context, c *conn, rcptTo smtp.Path, m dsn.Message, requireTLS bool) error {
buf, err := m.Compose(c.log, false) buf, err := m.Compose(c.log, false)
if err != nil { if err != nil {
return err return err
@ -46,7 +46,11 @@ func queueDSN(ctx context.Context, c *conn, rcptTo smtp.Path, m dsn.Message) err
// ../rfc/3464:433 // ../rfc/3464:433
const has8bit = false const has8bit = false
const smtputf8 = false const smtputf8 = false
if _, err := queue.Add(ctx, c.log, "", smtp.Path{}, rcptTo, has8bit, smtputf8, int64(len(buf)), m.MessageID, nil, f, bufUTF8); err != nil { var reqTLS *bool
if requireTLS {
reqTLS = &requireTLS
}
if _, err := queue.Add(ctx, c.log, "", smtp.Path{}, rcptTo, has8bit, smtputf8, int64(len(buf)), m.MessageID, nil, f, bufUTF8, reqTLS); err != nil {
return err return err
} }
return nil return nil

View file

@ -100,7 +100,7 @@ func FuzzServer(f *testing.F) {
const submission = false const submission = false
err := serverConn.SetDeadline(time.Now().Add(time.Second)) err := serverConn.SetDeadline(time.Now().Add(time.Second))
flog(err, "set server deadline") flog(err, "set server deadline")
serve("test", cid, dns.Domain{ASCII: "mox.example"}, nil, serverConn, resolver, submission, false, 100<<10, false, false, nil, 0) serve("test", cid, dns.Domain{ASCII: "mox.example"}, nil, serverConn, resolver, submission, false, 100<<10, false, false, false, nil, 0)
cid++ cid++
} }

View file

@ -17,6 +17,7 @@ import (
"io" "io"
"math" "math"
"net" "net"
"net/textproto"
"os" "os"
"runtime/debug" "runtime/debug"
"sort" "sort"
@ -201,7 +202,7 @@ func Listen() {
port := config.Port(listener.SMTP.Port, 25) port := config.Port(listener.SMTP.Port, 25)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
firstTimeSenderDelay := durationDefault(listener.SMTP.FirstTimeSenderDelay, firstTimeSenderDelayDefault) firstTimeSenderDelay := durationDefault(listener.SMTP.FirstTimeSenderDelay, firstTimeSenderDelayDefault)
listen1("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, listener.SMTP.DNSBLZones, firstTimeSenderDelay) listen1("smtp", name, ip, port, hostname, tlsConfig, false, false, maxMsgSize, false, listener.SMTP.RequireSTARTTLS, !listener.SMTP.NoRequireTLS, listener.SMTP.DNSBLZones, firstTimeSenderDelay)
} }
} }
if listener.Submission.Enabled { if listener.Submission.Enabled {
@ -211,7 +212,7 @@ func Listen() {
} }
port := config.Port(listener.Submission.Port, 587) port := config.Port(listener.Submission.Port, 587)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
listen1("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, nil, 0) listen1("submission", name, ip, port, hostname, tlsConfig, true, false, maxMsgSize, !listener.Submission.NoRequireSTARTTLS, !listener.Submission.NoRequireSTARTTLS, true, nil, 0)
} }
} }
@ -222,7 +223,7 @@ func Listen() {
} }
port := config.Port(listener.Submissions.Port, 465) port := config.Port(listener.Submissions.Port, 465)
for _, ip := range listener.IPs { for _, ip := range listener.IPs {
listen1("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, nil, 0) listen1("submissions", name, ip, port, hostname, tlsConfig, true, true, maxMsgSize, true, true, true, nil, 0)
} }
} }
} }
@ -230,7 +231,7 @@ func Listen() {
var servers []func() var servers []func()
func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) { func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig *tls.Config, submission, xtls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) {
addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port)) addr := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
if os.Getuid() == 0 { if os.Getuid() == 0 {
xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol)) xlog.Print("listening for smtp", mlog.Field("listener", name), mlog.Field("address", addr), mlog.Field("protocol", protocol))
@ -252,7 +253,7 @@ func listen1(protocol, name, ip string, port int, hostname dns.Domain, tlsConfig
continue continue
} }
resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc. resolver := dns.StrictResolver{} // By leaving Pkg empty, it'll be set by each package that uses the resolver, e.g. spf/dkim/dmarc.
go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, dnsBLs, firstTimeSenderDelay) go serve(name, mox.Cid(), hostname, tlsConfig, conn, resolver, submission, xtls, maxMessageSize, requireTLSForAuth, requireTLSForDelivery, requireTLS, dnsBLs, firstTimeSenderDelay)
} }
} }
@ -277,6 +278,7 @@ type conn struct {
conn net.Conn conn net.Conn
tls bool tls bool
extRequireTLS bool // Whether to announce and allow the REQUIRETLS extension.
resolver dns.Resolver resolver dns.Resolver
r *bufio.Reader r *bufio.Reader
w *bufio.Writer w *bufio.Writer
@ -316,8 +318,9 @@ type conn struct {
// Message transaction. // Message transaction.
mailFrom *smtp.Path mailFrom *smtp.Path
has8bitmime bool // If MAIL FROM parameter BODY=8BITMIME was sent. Required for SMTPUTF8. requireTLS *bool // MAIL FROM with REQUIRETLS set.
smtputf8 bool // todo future: we should keep track of this per recipient. perhaps only a specific recipient requires smtputf8, e.g. due to a utf8 localpart. we should decide ourselves if the message needs smtputf8, e.g. due to utf8 header values. has8bitmime bool // If MAIL FROM parameter BODY=8BITMIME was sent. Required for SMTPUTF8.
smtputf8 bool // todo future: we should keep track of this per recipient. perhaps only a specific recipient requires smtputf8, e.g. due to a utf8 localpart. we should decide ourselves if the message needs smtputf8, e.g. due to utf8 header values.
recipients []rcptAccount recipients []rcptAccount
} }
@ -353,6 +356,7 @@ func (c *conn) reset() {
// ../rfc/5321:2502 // ../rfc/5321:2502
func (c *conn) rset() { func (c *conn) rset() {
c.mailFrom = nil c.mailFrom = nil
c.requireTLS = nil
c.has8bitmime = false c.has8bitmime = false
c.smtputf8 = false c.smtputf8 = false
c.recipients = nil c.recipients = nil
@ -524,7 +528,7 @@ func (c *conn) writelinef(format string, args ...any) {
var cleanClose struct{} // Sentinel value for panic/recover indicating clean close of connection. var cleanClose struct{} // Sentinel value for panic/recover indicating clean close of connection.
func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, tls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) { func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.Config, nc net.Conn, resolver dns.Resolver, submission, tls bool, maxMessageSize int64, requireTLSForAuth, requireTLSForDelivery, requireTLS bool, dnsBLs []dns.Domain, firstTimeSenderDelay time.Duration) {
var localIP, remoteIP net.IP var localIP, remoteIP net.IP
if a, ok := nc.LocalAddr().(*net.TCPAddr); ok { if a, ok := nc.LocalAddr().(*net.TCPAddr); ok {
localIP = a.IP localIP = a.IP
@ -545,6 +549,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C
conn: nc, conn: nc,
submission: submission, submission: submission,
tls: tls, tls: tls,
extRequireTLS: requireTLS,
resolver: resolver, resolver: resolver,
lastlog: time.Now(), lastlog: time.Now(),
tlsConfig: tlsConfig, tlsConfig: tlsConfig,
@ -821,6 +826,10 @@ func (c *conn) cmdHello(p *parser, ehlo bool) {
if !c.tls && c.tlsConfig != nil { if !c.tls && c.tlsConfig != nil {
// ../rfc/3207:90 // ../rfc/3207:90
c.bwritelinef("250-STARTTLS") c.bwritelinef("250-STARTTLS")
} else if c.extRequireTLS {
// ../rfc/8689:202
// ../rfc/8689:143
c.bwritelinef("250-REQUIRETLS")
} }
if c.submission { if c.submission {
// ../rfc/4954:123 // ../rfc/4954:123
@ -1285,6 +1294,15 @@ func (c *conn) cmdMail(p *parser) {
case "SMTPUTF8": case "SMTPUTF8":
// ../rfc/6531:213 // ../rfc/6531:213
c.smtputf8 = true c.smtputf8 = true
case "REQUIRETLS":
// ../rfc/8689:155
if !c.tls {
xsmtpUserErrorf(smtp.C530SecurityRequired, smtp.SePol7EncNeeded10, "requiretls only allowed on tls-encrypted connections")
} else if !c.extRequireTLS {
xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "REQUIRETLS not allowed for this connection")
}
v := true
c.requireTLS = &v
default: default:
// ../rfc/5321:2230 // ../rfc/5321:2230
xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "unrecognized parameter %q", key) xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "unrecognized parameter %q", key)
@ -1645,7 +1663,12 @@ func (c *conn) cmdData(p *parser) {
recvHdr := &message.HeaderWriter{} recvHdr := &message.HeaderWriter{}
// For additional Received-header clauses, see: // For additional Received-header clauses, see:
// https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8 // https://www.iana.org/assignments/mail-parameters/mail-parameters.xhtml#table-mail-parameters-8
recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "via", "tcp", "with", with, "id", mox.ReceivedID(c.cid)) // ../rfc/5321:3158 withComment := ""
if c.requireTLS != nil && *c.requireTLS {
// Comment is actually part of ID ABNF rule. ../rfc/5321:3336
withComment = " (requiretls)"
}
recvHdr.Add(" ", "Received:", "from", recvFrom, "by", recvBy, "via", "tcp", "with", with+withComment, "id", mox.ReceivedID(c.cid)) // ../rfc/5321:3158
if c.tls { if c.tls {
tlsConn := c.conn.(*tls.Conn) tlsConn := c.conn.(*tls.Conn)
tlsComment := message.TLSReceivedComment(c.log, tlsConn.ConnectionState()) tlsComment := message.TLSReceivedComment(c.log, tlsConn.ConnectionState())
@ -1665,6 +1688,23 @@ func (c *conn) cmdData(p *parser) {
} }
} }
// Check if a message has unambiguous "TLS-Required: No" header. Messages must not
// contain multiple TLS-Required headers. The only valid value is "no". But we'll
// accept multiple headers as long as all they are all "no".
// ../rfc/8689:223
func hasTLSRequiredNo(h textproto.MIMEHeader) bool {
l := h.Values("Tls-Required")
if len(l) == 0 {
return false
}
for _, v := range l {
if !strings.EqualFold(v, "no") {
return false
}
}
return true
}
// submit is used for mail from authenticated users that we will try to deliver. // submit is used for mail from authenticated users that we will try to deliver.
func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, dataFile *os.File) { func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWriter *message.Writer, dataFile *os.File) {
// Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\( // Similar between ../smtpserver/server.go:/submit\( and ../webmail/webmail.go:/MessageSubmit\(
@ -1692,6 +1732,14 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user") xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SePol7DeliveryUnauth1, "must match authenticated user")
} }
// TLS-Required: No header makes us not enforce recipient domain's TLS policy.
// ../rfc/8689:206
// Only when requiretls smtp extension wasn't used. ../rfc/8689:246
if c.requireTLS == nil && hasTLSRequiredNo(header) {
v := false
c.requireTLS = &v
}
// Outgoing messages should not have a Return-Path header. The final receiving mail // Outgoing messages should not have a Return-Path header. The final receiving mail
// server will add it. // server will add it.
// ../rfc/5321:3233 // ../rfc/5321:3233
@ -1787,7 +1835,7 @@ func (c *conn) submit(ctx context.Context, recvHdrFor func(string) string, msgWr
xmsgPrefix := append([]byte(recvHdrFor(rcptAcc.rcptTo.String())), msgPrefix...) xmsgPrefix := append([]byte(recvHdrFor(rcptAcc.rcptTo.String())), msgPrefix...)
msgSize := int64(len(xmsgPrefix)) + msgWriter.Size msgSize := int64(len(xmsgPrefix)) + msgWriter.Size
if _, err := queue.Add(ctx, c.log, c.account.Name, *c.mailFrom, rcptAcc.rcptTo, msgWriter.Has8bit, c.smtputf8, msgSize, messageID, xmsgPrefix, dataFile, nil); err != nil { if _, err := queue.Add(ctx, c.log, c.account.Name, *c.mailFrom, rcptAcc.rcptTo, msgWriter.Has8bit, c.smtputf8, msgSize, messageID, xmsgPrefix, dataFile, nil, c.requireTLS); err != nil {
// Aborting the transaction is not great. But continuing and generating DSNs will // Aborting the transaction is not great. But continuing and generating DSNs will
// probably result in errors as well... // probably result in errors as well...
metricSubmission.WithLabelValues("queueerror").Inc() metricSubmission.WithLabelValues("queueerror").Inc()
@ -1872,6 +1920,16 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeNet4Loop6, "loop detected, more than 100 Received headers") xsmtpUserErrorf(smtp.C550MailboxUnavail, smtp.SeNet4Loop6, "loop detected, more than 100 Received headers")
} }
// TLS-Required: No header makes us not enforce recipient domain's TLS policy.
// Since we only deliver locally at the moment, this won't influence our behaviour.
// Once we forward, it would our delivery attempts.
// ../rfc/8689:206
// Only when requiretls smtp extension wasn't used. ../rfc/8689:246
if c.requireTLS == nil && hasTLSRequiredNo(headers) {
v := false
c.requireTLS = &v
}
// We'll be building up an Authentication-Results header. // We'll be building up an Authentication-Results header.
authResults := message.AuthResults{ authResults := message.AuthResults{
Hostname: mox.Conf.Static.HostnameDomain.XName(c.smtputf8), Hostname: mox.Conf.Static.HostnameDomain.XName(c.smtputf8),
@ -2547,7 +2605,7 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
if Localserve { if Localserve {
c.log.Error("not queueing dsn for incoming delivery due to localserve") c.log.Error("not queueing dsn for incoming delivery due to localserve")
} else if err := queueDSN(context.TODO(), c, *c.mailFrom, dsnMsg); err != nil { } else if err := queueDSN(context.TODO(), c, *c.mailFrom, dsnMsg, c.requireTLS != nil && *c.requireTLS); err != nil {
metricServerErrors.WithLabelValues("queuedsn").Inc() metricServerErrors.WithLabelValues("queuedsn").Inc()
c.log.Errorx("queuing DSN for incoming delivery, no DSN sent", err) c.log.Errorx("queuing DSN for incoming delivery, no DSN sent", err)
} }

View file

@ -90,6 +90,7 @@ type testserver struct {
auth []sasl.Client auth []sasl.Client
user, pass string user, pass string
submission bool submission bool
requiretls bool
dnsbls []dns.Domain dnsbls []dns.Domain
tlsmode smtpclient.TLSMode tlsmode smtpclient.TLSMode
} }
@ -145,7 +146,7 @@ func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
Certificates: []tls.Certificate{fakeCert(ts.t)}, Certificates: []tls.Certificate{fakeCert(ts.t)},
} }
serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.dnsbls, 0) serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.requiretls, ts.dnsbls, 0)
close(serverdone) close(serverdone)
}() }()
@ -222,7 +223,7 @@ func TestSubmission(t *testing.T) {
mailFrom := "mjl@mox.example" mailFrom := "mjl@mox.example"
rcptTo := "remote@example.org" rcptTo := "remote@example.org"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) { if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
@ -261,7 +262,7 @@ func TestDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@127.0.0.10" rcptTo := "mjl@127.0.0.10"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
@ -273,7 +274,7 @@ func TestDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@test.example" // Not configured as destination. rcptTo := "mjl@test.example" // Not configured as destination.
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
@ -285,7 +286,7 @@ func TestDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "unknown@mox.example" // User unknown. rcptTo := "unknown@mox.example" // User unknown.
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
@ -297,7 +298,7 @@ func TestDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
@ -311,7 +312,7 @@ func TestDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
tcheck(t, err, "deliver to remote") tcheck(t, err, "deliver to remote")
@ -442,7 +443,7 @@ func TestSpam(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
@ -458,7 +459,7 @@ func TestSpam(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl2@mox.example" rcptTo := "mjl2@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage2)), strings.NewReader(deliverMessage2), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage2)), strings.NewReader(deliverMessage2), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -477,7 +478,7 @@ func TestSpam(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -499,7 +500,7 @@ func TestSpam(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
@ -576,7 +577,7 @@ happens to come from forwarding mail server.
} }
for i := 0; i < 10; i++ { for i := 0; i < 10; i++ {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgBad)), strings.NewReader(msgBad), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgBad)), strings.NewReader(msgBad), false, false, false)
tcheck(t, err, "deliver message") tcheck(t, err, "deliver message")
} }
@ -585,7 +586,7 @@ happens to come from forwarding mail server.
tcompare(t, n, 10) tcompare(t, n, 10)
// Next delivery will fail, with negative "message From" signal. // Next delivery will fail, with negative "message From" signal.
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgBad)), strings.NewReader(msgBad), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgBad)), strings.NewReader(msgBad), false, false, false)
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
t.Fatalf("delivery by bad sender, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr) t.Fatalf("delivery by bad sender, got err %v, expected smtpclient.Error with code %d", err, smtp.C451LocalErr)
@ -603,7 +604,7 @@ happens to come from forwarding mail server.
mailFrom = "remote@good.example" mailFrom = "remote@good.example"
} }
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK)), strings.NewReader(msgOK), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK)), strings.NewReader(msgOK), false, false, false)
if forward { if forward {
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
} else { } else {
@ -620,7 +621,7 @@ happens to come from forwarding mail server.
mailFrom := "other@forward.example" mailFrom := "other@forward.example"
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK2)), strings.NewReader(msgOK2), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msgOK2)), strings.NewReader(msgOK2), false, false, false)
if forward { if forward {
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
} else { } else {
@ -669,7 +670,7 @@ func TestDMARCSent(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
@ -688,7 +689,7 @@ func TestDMARCSent(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
}) })
@ -721,7 +722,7 @@ func TestBlocklistedSubjectpass(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr {
@ -740,7 +741,7 @@ func TestBlocklistedSubjectpass(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail {
@ -758,7 +759,7 @@ func TestBlocklistedSubjectpass(t *testing.T) {
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
passMessage := strings.Replace(deliverMessage, "Subject: test", "Subject: test "+pass, 1) passMessage := strings.Replace(deliverMessage, "Subject: test", "Subject: test "+pass, 1)
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(passMessage)), strings.NewReader(passMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(passMessage)), strings.NewReader(passMessage), false, false, false)
} }
tcheck(t, err, "deliver with subjectpass") tcheck(t, err, "deliver with subjectpass")
}) })
@ -800,7 +801,7 @@ func TestDMARCReport(t *testing.T) {
msg := msgb.String() msg := msgb.String()
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -922,7 +923,7 @@ func TestTLSReport(t *testing.T) {
msg = headers + msg msg = headers + msg
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -1021,11 +1022,11 @@ func TestRatelimitDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
tcheck(t, err, "deliver to remote") tcheck(t, err, "deliver to remote")
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C452StorageFull { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C452StorageFull {
t.Fatalf("got err %v, expected smtpclient error with code 452 for storage full", err) t.Fatalf("got err %v, expected smtpclient error with code 452 for storage full", err)
@ -1050,11 +1051,11 @@ func TestRatelimitDelivery(t *testing.T) {
mailFrom := "remote@example.org" mailFrom := "remote@example.org"
rcptTo := "mjl@mox.example" rcptTo := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
tcheck(t, err, "deliver to remote") tcheck(t, err, "deliver to remote")
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
var cerr smtpclient.Error var cerr smtpclient.Error
if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C452StorageFull { if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C452StorageFull {
t.Fatalf("got err %v, expected smtpclient error with code 452 for storage full", err) t.Fatalf("got err %v, expected smtpclient error with code 452 for storage full", err)
@ -1076,7 +1077,7 @@ func TestNonSMTP(t *testing.T) {
tlsConfig := &tls.Config{ tlsConfig := &tls.Config{
Certificates: []tls.Certificate{fakeCert(ts.t)}, Certificates: []tls.Certificate{fakeCert(ts.t)},
} }
serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, ts.dnsbls, 0) serve("test", ts.cid-2, dns.Domain{ASCII: "mox.example"}, tlsConfig, serverConn, ts.resolver, ts.submission, false, 100<<20, false, false, false, ts.dnsbls, 0)
close(serverdone) close(serverdone)
}() }()
@ -1123,7 +1124,7 @@ func TestLimitOutgoing(t *testing.T) {
t.Helper() t.Helper()
mailFrom := "mjl@mox.example" mailFrom := "mjl@mox.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) { if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
@ -1160,7 +1161,7 @@ func TestCatchall(t *testing.T) {
t.Helper() t.Helper()
mailFrom := "mjl@other.example" mailFrom := "mjl@other.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(submitMessage)), strings.NewReader(submitMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) { if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Secode != expErr.Secode) {
@ -1253,7 +1254,7 @@ test email
rcptTo := "remote@example.org" rcptTo := "remote@example.org"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, false)
} }
tcheck(t, err, "deliver") tcheck(t, err, "deliver")
@ -1298,7 +1299,7 @@ func TestPostmaster(t *testing.T) {
t.Helper() t.Helper()
mailFrom := "mjl@other.example" mailFrom := "mjl@other.example"
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) { if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) {
@ -1332,7 +1333,7 @@ func TestEmptylocalpart(t *testing.T) {
t.Helper() t.Helper()
mailFrom := `""@other.example` mailFrom := `""@other.example`
if err == nil { if err == nil {
err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false) err = client.Deliver(ctxbg, mailFrom, rcptTo, int64(len(deliverMessage)), strings.NewReader(deliverMessage), false, false, false)
} }
var cerr smtpclient.Error var cerr smtpclient.Error
if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) { if expErr == nil && err != nil || expErr != nil && (err == nil || !errors.As(err, &cerr) || cerr.Code != expErr.Code || cerr.Secode != expErr.Secode) {
@ -1343,3 +1344,98 @@ func TestEmptylocalpart(t *testing.T) {
testDeliver(`""@mox.example`, nil) testDeliver(`""@mox.example`, nil)
} }
// Test handling REQUIRETLS and TLS-Required: No.
func TestRequireTLS(t *testing.T) {
resolver := dns.MockResolver{
A: map[string][]string{
"mox.example.": {"127.0.0.10"}, // For mx check.
},
PTR: map[string][]string{
"127.0.0.10": {"mox.example."},
},
}
ts := newTestServer(t, filepath.FromSlash("../testdata/smtp/mox.conf"), resolver)
defer ts.close()
ts.submission = true
ts.requiretls = true
ts.user = "mjl@mox.example"
ts.pass = "testtest"
no := false
yes := true
msg0 := strings.ReplaceAll(`From: <mjl@mox.example>
To: <remote@example.org>
Subject: test
Message-Id: <test@mox.example>
TLS-Required: No
test email
`, "\n", "\r\n")
msg1 := strings.ReplaceAll(`From: <mjl@mox.example>
To: <remote@example.org>
Subject: test
Message-Id: <test@mox.example>
TLS-Required: No
TLS-Required: bogus
test email
`, "\n", "\r\n")
msg2 := strings.ReplaceAll(`From: <mjl@mox.example>
To: <remote@example.org>
Subject: test
Message-Id: <test@mox.example>
test email
`, "\n", "\r\n")
testSubmit := func(msg string, requiretls bool, expRequireTLS *bool) {
t.Helper()
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
rcptTo := "remote@example.org"
if err == nil {
err = client.Deliver(ctxbg, "mjl@mox.example", rcptTo, int64(len(msg)), strings.NewReader(msg), false, false, requiretls)
}
tcheck(t, err, "deliver")
msgs, err := queue.List(ctxbg)
tcheck(t, err, "listing queue")
tcompare(t, len(msgs), 1)
tcompare(t, msgs[0].RequireTLS, expRequireTLS)
_, err = queue.Drop(ctxbg, msgs[0].ID, "", "")
tcheck(t, err, "deleting message from queue")
})
}
testSubmit(msg0, true, &yes) // Header ignored, requiretls applied.
testSubmit(msg0, false, &no) // TLS-Required header applied.
testSubmit(msg1, true, &yes) // Bad headers ignored, requiretls applied.
testSubmit(msg1, false, nil) // Inconsistent multiple headers ignored.
testSubmit(msg2, false, nil) // Regular message, no RequireTLS setting.
testSubmit(msg2, true, &yes) // Requiretls applied.
// Check that we get an error if remote SMTP server does not support the requiretls
// extension.
ts.requiretls = false
ts.run(func(err error, client *smtpclient.Client) {
t.Helper()
rcptTo := "remote@example.org"
if err == nil {
err = client.Deliver(ctxbg, "mjl@mox.example", rcptTo, int64(len(msg0)), strings.NewReader(msg0), false, false, true)
}
if err == nil {
t.Fatalf("delivered with requiretls to server without requiretls")
}
if !errors.Is(err, smtpclient.ErrRequireTLSUnsupported) {
t.Fatalf("got err %v, expected ErrRequireTLSUnsupported", err)
}
})
}

View file

@ -669,8 +669,17 @@ type Outgoing struct {
Submitted time.Time `bstore:"nonzero,default now"` Submitted time.Time `bstore:"nonzero,default now"`
} }
// RecipientDomainTLS stores TLS capabilities of a recipient domain as encountered
// during most recent connection (delivery attempt).
type RecipientDomainTLS struct {
Domain string // Unicode.
Updated time.Time `bstore:"default now"`
STARTTLS bool // Supports STARTTLS.
RequireTLS bool // Supports RequireTLS SMTP extension.
}
// Types stored in DB. // Types stored in DB.
var DBTypes = []any{NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Outgoing{}, Password{}, Subjectpass{}, SyncState{}, Upgrade{}} var DBTypes = []any{NextUIDValidity{}, Message{}, Recipient{}, Mailbox{}, Subscription{}, Outgoing{}, Password{}, Subjectpass{}, SyncState{}, Upgrade{}, RecipientDomainTLS{}}
// Account holds the information about a user, includings mailboxes, messages, imap subscriptions. // Account holds the information about a user, includings mailboxes, messages, imap subscriptions.
type Account struct { type Account struct {

View file

@ -1823,6 +1823,13 @@ func (Admin) QueueDrop(ctx context.Context, id int64) {
xcheckf(ctx, err, "drop message from queue") xcheckf(ctx, err, "drop message from queue")
} }
// QueueSaveRequireTLS updates the requiretls field for a message in the queue,
// to be used for the next delivery.
func (Admin) QueueSaveRequireTLS(ctx context.Context, id int64, requireTLS *bool) {
err := queue.SaveRequireTLS(ctx, id, requireTLS)
xcheckf(ctx, err, "update requiretls for message in queue")
}
// LogLevels returns the current log levels. // LogLevels returns the current log levels.
func (Admin) LogLevels(ctx context.Context) map[string]string { func (Admin) LogLevels(ctx context.Context) map[string]string {
m := map[string]string{} m := map[string]string{}

View file

@ -1537,7 +1537,6 @@ const queueList = async () => {
]) ])
const nowSecs = new Date().getTime()/1000 const nowSecs = new Date().getTime()/1000
let transport
const page = document.getElementById('page') const page = document.getElementById('page')
dom._kids(page, dom._kids(page,
@ -1560,63 +1559,97 @@ const queueList = async () => {
dom.th('Next attempt'), dom.th('Next attempt'),
dom.th('Last attempt'), dom.th('Last attempt'),
dom.th('Last error'), dom.th('Last error'),
dom.th('Require TLS'),
dom.th('Transport/Retry'), dom.th('Transport/Retry'),
dom.th('Remove'), dom.th('Remove'),
), ),
), ),
dom.tbody( dom.tbody(
msgs.map(m => dom.tr( msgs.map(m => {
dom.td(''+m.ID), let requiretls, requiretlsFieldset, transport
dom.td(age(new Date(m.Queued), false, nowSecs)), return dom.tr(
dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart dom.td(''+m.ID),
dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart dom.td(age(new Date(m.Queued), false, nowSecs)),
dom.td(formatSize(m.Size)), dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart
dom.td(''+m.Attempts), dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart
dom.td(age(new Date(m.NextAttempt), true, nowSecs)), dom.td(formatSize(m.Size)),
dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), dom.td(''+m.Attempts),
dom.td(m.LastError || '-'), dom.td(age(new Date(m.NextAttempt), true, nowSecs)),
dom.td( dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'),
transport=dom.select( dom.td(m.LastError || '-'),
attr({title: 'Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'}), dom.td(
dom.option('(default)', attr({value: ''})), dom.form(
Object.keys(transports).sort().map(t => dom.option(t, m.Transport === t ? attr({checked: ''}) : [])), requiretlsFieldset=dom.fieldset(
requiretls=dom.select(
attr({title: 'How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'}),
dom.option('Default', attr({value: ''})),
dom.option('With RequireTLS', attr({value: 'yes'}), m.RequireTLS === true ? attr({selected: ''}) : []),
dom.option('Fallback to insecure', attr({value: 'no'}), m.RequireTLS === false ? attr({selected: ''}) : []),
),
' ',
dom.button('Save'),
),
async function submit(e) {
e.preventDefault()
try {
requiretlsFieldset.disabled = true
await api.QueueSaveRequireTLS(m.ID, requiretls.value === '' ? null : requiretls.value === 'yes')
} catch (err) {
console.log({err})
window.alert('Error: ' + err.message)
return
} finally {
requiretlsFieldset.disabled = false
}
}
),
), ),
' ', dom.td(
dom.button('Retry now', async function click(e) { dom.form(
e.preventDefault() transport=dom.select(
try { attr({title: 'Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'}),
e.target.disabled = true dom.option('(default)', attr({value: ''})),
await api.QueueKick(m.ID, transport.value) Object.keys(transports).sort().map(t => dom.option(t, m.Transport === t ? attr({checked: ''}) : [])),
} catch (err) { ),
console.log({err}) ' ',
window.alert('Error: ' + err.message) dom.button('Retry now'),
return async function submit(e) {
} finally { e.preventDefault()
e.target.disabled = false try {
} e.target.disabled = true
window.location.reload() // todo: only refresh the list await api.QueueKick(m.ID, transport.value)
}), } catch (err) {
), console.log({err})
dom.td( window.alert('Error: ' + err.message)
dom.button('Remove', async function click(e) { return
e.preventDefault() } finally {
if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { e.target.disabled = false
return }
} window.location.reload() // todo: only refresh the list
try { }
e.target.disabled = true ),
await api.QueueDrop(m.ID) ),
} catch (err) { dom.td(
console.log({err}) dom.button('Remove', async function click(e) {
window.alert('Error: ' + err.message) e.preventDefault()
return if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) {
} finally { return
e.target.disabled = false }
} try {
window.location.reload() // todo: only refresh the list e.target.disabled = true
}), await api.QueueDrop(m.ID)
), } catch (err) {
)), console.log({err})
window.alert('Error: ' + err.message)
return
} finally {
e.target.disabled = false
}
window.location.reload() // todo: only refresh the list
}),
),
)
})
), ),
), ),
], ],

View file

@ -622,6 +622,26 @@
], ],
"Returns": [] "Returns": []
}, },
{
"Name": "QueueSaveRequireTLS",
"Docs": "QueueSaveRequireTLS updates the requiretls field for a message in the queue,\nto be used for the next delivery.",
"Params": [
{
"Name": "id",
"Typewords": [
"int64"
]
},
{
"Name": "requireTLS",
"Typewords": [
"nullable",
"bool"
]
}
],
"Returns": []
},
{ {
"Name": "LogLevels", "Name": "LogLevels",
"Docs": "LogLevels returns the current log levels.", "Docs": "LogLevels returns the current log levels.",
@ -3040,6 +3060,14 @@
"Typewords": [ "Typewords": [
"string" "string"
] ]
},
{
"Name": "RequireTLS",
"Docs": "RequireTLS influences TLS verification during delivery. If nil, the recipient domain policy is followed (MTA-STS and/or DANE), falling back to optional opportunistic non-verified STARTTLS. If RequireTLS is true (through SMTP REQUIRETLS extension or webmail submit), MTA-STS or DANE is required, as well as REQUIRETLS support by the next hop server. If RequireTLS is false (through messag header \"TLS-Required: No\"), the recipient domain's policy is ignored if it does not lead to a successful TLS connection, i.e. falling back to SMTP delivery with unverified STARTTLS or plain text.",
"Typewords": [
"nullable",
"bool"
]
} }
] ]
}, },

View file

@ -16,6 +16,7 @@ import (
"net/mail" "net/mail"
"net/textproto" "net/textproto"
"os" "os"
"runtime/debug"
"sort" "sort"
"strings" "strings"
"sync" "sync"
@ -33,6 +34,7 @@ import (
"github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dkim"
"github.com/mjl-/mox/dns" "github.com/mjl-/mox/dns"
"github.com/mjl-/mox/message" "github.com/mjl-/mox/message"
"github.com/mjl-/mox/metrics"
"github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mlog"
"github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mox-"
"github.com/mjl-/mox/moxio" "github.com/mjl-/mox/moxio"
@ -162,6 +164,7 @@ type SubmitMessage struct {
ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward. ResponseMessageID int64 // If set, this was a reply or forward, based on IsForward.
ReplyTo string // If non-empty, Reply-To header to add to message. ReplyTo string // If non-empty, Reply-To header to add to message.
UserAgent string // User-Agent header added if not empty. UserAgent string // User-Agent header added if not empty.
RequireTLS *bool // For "Require TLS" extension during delivery.
} }
// ForwardAttachments references attachments by a list of message.Part paths. // ForwardAttachments references attachments by a list of message.Part paths.
@ -522,6 +525,9 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
if m.UserAgent != "" { if m.UserAgent != "" {
header("User-Agent", m.UserAgent) header("User-Agent", m.UserAgent)
} }
if m.RequireTLS != nil && !*m.RequireTLS {
header("TLS-Required", "No")
}
header("MIME-Version", "1.0") header("MIME-Version", "1.0")
if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 { if len(m.Attachments) > 0 || len(m.ForwardAttachments.Paths) > 0 {
@ -685,7 +691,7 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) {
Localpart: rcpt.Localpart, Localpart: rcpt.Localpart,
IPDomain: dns.IPDomain{Domain: rcpt.Domain}, IPDomain: dns.IPDomain{Domain: rcpt.Domain},
} }
_, err := queue.Add(ctx, log, reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), dataFile, nil) _, err := queue.Add(ctx, log, reqInfo.AccountName, fromPath, toPath, has8bit, smtputf8, msgSize, messageID, []byte(rcptMsgPrefix), dataFile, nil, m.RequireTLS)
if err != nil { if err != nil {
metricSubmission.WithLabelValues("queueerror").Inc() metricSubmission.WithLabelValues("queueerror").Inc()
} }
@ -1635,12 +1641,27 @@ const (
SecurityResultUnknown SecurityResult = "unknown" SecurityResultUnknown SecurityResult = "unknown"
) )
// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain). // RecipientSecurity is a quick analysis of the security properties of delivery to
// Fields are nil when an error occurred during analysis. // the recipient (domain).
type RecipientSecurity struct { type RecipientSecurity struct {
MTASTS SecurityResult // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record. // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most
DNSSEC SecurityResult // Whether MX lookup response was DNSSEC-signed. // recent delivery attempt. Will be "unknown" if no delivery to the domain has been
DANE SecurityResult // Whether first delivery destination has DANE records. // attempted yet.
STARTTLS SecurityResult
// Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS
// record.
MTASTS SecurityResult
// Whether MX lookup response was DNSSEC-signed.
DNSSEC SecurityResult
// Whether first delivery destination has DANE records.
DANE SecurityResult
// Whether recipient domain is known to implement the REQUIRETLS SMTP extension.
// Will be "unknown" if no delivery to the domain has been attempted yet.
RequireTLS SecurityResult
} }
// RecipientSecurity looks up security properties of the address in the // RecipientSecurity looks up security properties of the address in the
@ -1650,6 +1671,18 @@ func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) (
return recipientSecurity(ctx, resolver, messageAddressee) return recipientSecurity(ctx, resolver, messageAddressee)
} }
// logPanic can be called with a defer from a goroutine to prevent the entire program from being shutdown in case of a panic.
func logPanic(ctx context.Context) {
x := recover()
if x == nil {
return
}
log := xlog.WithContext(ctx)
log.Error("recover from panic", mlog.Field("panic", x))
debug.PrintStack()
metrics.PanicInc(metrics.Webmail)
}
// separate function for testing with mocked resolver. // separate function for testing with mocked resolver.
func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) { func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) {
log := xlog.WithContext(ctx) log := xlog.WithContext(ctx)
@ -1658,6 +1691,8 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres
SecurityResultUnknown, SecurityResultUnknown,
SecurityResultUnknown, SecurityResultUnknown,
SecurityResultUnknown, SecurityResultUnknown,
SecurityResultUnknown,
SecurityResultUnknown,
} }
msgAddr, err := mail.ParseAddress(messageAddressee) msgAddr, err := mail.ParseAddress(messageAddressee)
@ -1675,6 +1710,7 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres
// MTA-STS. // MTA-STS.
wg.Add(1) wg.Add(1)
go func() { go func() {
defer logPanic(ctx)
defer wg.Done() defer wg.Done()
policy, _, err := mtastsdb.Get(ctx, resolver, addr.Domain) policy, _, err := mtastsdb.Get(ctx, resolver, addr.Domain)
@ -1690,6 +1726,7 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres
// DNSSEC and DANE. // DNSSEC and DANE.
wg.Add(1) wg.Add(1)
go func() { go func() {
defer logPanic(ctx)
defer wg.Done() defer wg.Done()
_, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log, resolver, dns.IPDomain{Domain: addr.Domain}) _, origNextHopAuthentic, expandedNextHopAuthentic, _, hosts, _, err := smtpclient.GatherDestinations(ctx, log, resolver, dns.IPDomain{Domain: addr.Domain})
@ -1737,7 +1774,51 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres
} }
}() }()
// STARTTLS and RequireTLS
reqInfo := ctx.Value(requestInfoCtxKey).(requestInfo)
acc, err := store.OpenAccount(reqInfo.AccountName)
xcheckf(ctx, err, "open account")
defer func() {
if acc != nil {
err := acc.Close()
log.Check(err, "closing account")
}
}()
err = acc.DB.Read(ctx, func(tx *bstore.Tx) error {
q := bstore.QueryTx[store.RecipientDomainTLS](tx)
q.FilterNonzero(store.RecipientDomainTLS{Domain: addr.Domain.Name()})
rd, err := q.Get()
if err == bstore.ErrAbsent {
return nil
} else if err != nil {
rs.STARTTLS = SecurityResultError
rs.RequireTLS = SecurityResultError
log.Errorx("looking up recipient domain", err, mlog.Field("domain", addr.Domain))
return nil
}
if rd.STARTTLS {
rs.STARTTLS = SecurityResultYes
} else {
rs.STARTTLS = SecurityResultNo
}
if rd.RequireTLS {
rs.RequireTLS = SecurityResultYes
} else {
rs.RequireTLS = SecurityResultNo
}
return nil
})
xcheckf(ctx, err, "lookup recipient domain")
// Close account as soon as possible, not after waiting for MTA-STS/DNSSEC/DANE
// checks to complete, which can take a while.
err = acc.Close()
log.Check(err, "closing account")
acc = nil
wg.Wait() wg.Wait()
return rs, nil return rs, nil
} }

View file

@ -1090,6 +1090,14 @@
"Typewords": [ "Typewords": [
"string" "string"
] ]
},
{
"Name": "RequireTLS",
"Docs": "For \"Require TLS\" extension during delivery.",
"Typewords": [
"nullable",
"bool"
]
} }
] ]
}, },
@ -1256,8 +1264,15 @@
}, },
{ {
"Name": "RecipientSecurity", "Name": "RecipientSecurity",
"Docs": "RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain).\nFields are nil when an error occurred during analysis.", "Docs": "RecipientSecurity is a quick analysis of the security properties of delivery to\nthe recipient (domain).",
"Fields": [ "Fields": [
{
"Name": "STARTTLS",
"Docs": "Whether recipient domain supports (opportunistic) STARTTLS, as seen during most recent delivery attempt. Will be \"unknown\" if no delivery to the domain has been attempted yet.",
"Typewords": [
"SecurityResult"
]
},
{ {
"Name": "MTASTS", "Name": "MTASTS",
"Docs": "Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.", "Docs": "Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.",
@ -1278,6 +1293,13 @@
"Typewords": [ "Typewords": [
"SecurityResult" "SecurityResult"
] ]
},
{
"Name": "RequireTLS",
"Docs": "Whether recipient domain is known to implement the REQUIRETLS SMTP extension. Will be \"unknown\" if no delivery to the domain has been attempted yet.",
"Typewords": [
"SecurityResult"
]
} }
] ]
}, },

View file

@ -142,6 +142,7 @@ export interface SubmitMessage {
ResponseMessageID: number // If set, this was a reply or forward, based on IsForward. ResponseMessageID: number // If set, this was a reply or forward, based on IsForward.
ReplyTo: string // If non-empty, Reply-To header to add to message. ReplyTo: string // If non-empty, Reply-To header to add to message.
UserAgent: string // User-Agent header added if not empty. UserAgent: string // User-Agent header added if not empty.
RequireTLS?: boolean | null // For "Require TLS" extension during delivery.
} }
// File is a new attachment (not from an existing message that is being // File is a new attachment (not from an existing message that is being
@ -177,12 +178,14 @@ export interface Mailbox {
Size: number // Number of bytes for all messages. Size: number // Number of bytes for all messages.
} }
// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain). // RecipientSecurity is a quick analysis of the security properties of delivery to
// Fields are nil when an error occurred during analysis. // the recipient (domain).
export interface RecipientSecurity { export interface RecipientSecurity {
STARTTLS: SecurityResult // Whether recipient domain supports (opportunistic) STARTTLS, as seen during most recent delivery attempt. Will be "unknown" if no delivery to the domain has been attempted yet.
MTASTS: SecurityResult // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record. MTASTS: SecurityResult // Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.
DNSSEC: SecurityResult // Whether MX lookup response was DNSSEC-signed. DNSSEC: SecurityResult // Whether MX lookup response was DNSSEC-signed.
DANE: SecurityResult // Whether first delivery destination has DANE records. DANE: SecurityResult // Whether first delivery destination has DANE records.
RequireTLS: SecurityResult // Whether recipient domain is known to implement the REQUIRETLS SMTP extension. Will be "unknown" if no delivery to the domain has been attempted yet.
} }
// EventStart is the first message sent on an SSE connection, giving the client // EventStart is the first message sent on an SSE connection, giving the client
@ -519,11 +522,11 @@ export const types: TypenameMap = {
"Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["string"]}]}, "Address": {"Name":"Address","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Host","Docs":"","Typewords":["string"]}]},
"MessageAddress": {"Name":"MessageAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]}, "MessageAddress": {"Name":"MessageAddress","Docs":"","Fields":[{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"User","Docs":"","Typewords":["string"]},{"Name":"Domain","Docs":"","Typewords":["Domain"]}]},
"Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]}, "Domain": {"Name":"Domain","Docs":"","Fields":[{"Name":"ASCII","Docs":"","Typewords":["string"]},{"Name":"Unicode","Docs":"","Typewords":["string"]}]},
"SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]}]}, "SubmitMessage": {"Name":"SubmitMessage","Docs":"","Fields":[{"Name":"From","Docs":"","Typewords":["string"]},{"Name":"To","Docs":"","Typewords":["[]","string"]},{"Name":"Cc","Docs":"","Typewords":["[]","string"]},{"Name":"Bcc","Docs":"","Typewords":["[]","string"]},{"Name":"Subject","Docs":"","Typewords":["string"]},{"Name":"TextBody","Docs":"","Typewords":["string"]},{"Name":"Attachments","Docs":"","Typewords":["[]","File"]},{"Name":"ForwardAttachments","Docs":"","Typewords":["ForwardAttachments"]},{"Name":"IsForward","Docs":"","Typewords":["bool"]},{"Name":"ResponseMessageID","Docs":"","Typewords":["int64"]},{"Name":"ReplyTo","Docs":"","Typewords":["string"]},{"Name":"UserAgent","Docs":"","Typewords":["string"]},{"Name":"RequireTLS","Docs":"","Typewords":["nullable","bool"]}]},
"File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]}, "File": {"Name":"File","Docs":"","Fields":[{"Name":"Filename","Docs":"","Typewords":["string"]},{"Name":"DataURI","Docs":"","Typewords":["string"]}]},
"ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]}, "ForwardAttachments": {"Name":"ForwardAttachments","Docs":"","Fields":[{"Name":"MessageID","Docs":"","Typewords":["int64"]},{"Name":"Paths","Docs":"","Typewords":["[]","[]","int32"]}]},
"Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]}, "Mailbox": {"Name":"Mailbox","Docs":"","Fields":[{"Name":"ID","Docs":"","Typewords":["int64"]},{"Name":"Name","Docs":"","Typewords":["string"]},{"Name":"UIDValidity","Docs":"","Typewords":["uint32"]},{"Name":"UIDNext","Docs":"","Typewords":["UID"]},{"Name":"Archive","Docs":"","Typewords":["bool"]},{"Name":"Draft","Docs":"","Typewords":["bool"]},{"Name":"Junk","Docs":"","Typewords":["bool"]},{"Name":"Sent","Docs":"","Typewords":["bool"]},{"Name":"Trash","Docs":"","Typewords":["bool"]},{"Name":"Keywords","Docs":"","Typewords":["[]","string"]},{"Name":"HaveCounts","Docs":"","Typewords":["bool"]},{"Name":"Total","Docs":"","Typewords":["int64"]},{"Name":"Deleted","Docs":"","Typewords":["int64"]},{"Name":"Unread","Docs":"","Typewords":["int64"]},{"Name":"Unseen","Docs":"","Typewords":["int64"]},{"Name":"Size","Docs":"","Typewords":["int64"]}]},
"RecipientSecurity": {"Name":"RecipientSecurity","Docs":"","Fields":[{"Name":"MTASTS","Docs":"","Typewords":["SecurityResult"]},{"Name":"DNSSEC","Docs":"","Typewords":["SecurityResult"]},{"Name":"DANE","Docs":"","Typewords":["SecurityResult"]}]}, "RecipientSecurity": {"Name":"RecipientSecurity","Docs":"","Fields":[{"Name":"STARTTLS","Docs":"","Typewords":["SecurityResult"]},{"Name":"MTASTS","Docs":"","Typewords":["SecurityResult"]},{"Name":"DNSSEC","Docs":"","Typewords":["SecurityResult"]},{"Name":"DANE","Docs":"","Typewords":["SecurityResult"]},{"Name":"RequireTLS","Docs":"","Typewords":["SecurityResult"]}]},
"EventStart": {"Name":"EventStart","Docs":"","Fields":[{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"LoginAddress","Docs":"","Typewords":["MessageAddress"]},{"Name":"Addresses","Docs":"","Typewords":["[]","MessageAddress"]},{"Name":"DomainAddressConfigs","Docs":"","Typewords":["{}","DomainAddressConfig"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Mailboxes","Docs":"","Typewords":["[]","Mailbox"]}]}, "EventStart": {"Name":"EventStart","Docs":"","Fields":[{"Name":"SSEID","Docs":"","Typewords":["int64"]},{"Name":"LoginAddress","Docs":"","Typewords":["MessageAddress"]},{"Name":"Addresses","Docs":"","Typewords":["[]","MessageAddress"]},{"Name":"DomainAddressConfigs","Docs":"","Typewords":["{}","DomainAddressConfig"]},{"Name":"MailboxName","Docs":"","Typewords":["string"]},{"Name":"Mailboxes","Docs":"","Typewords":["[]","Mailbox"]}]},
"DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]}, "DomainAddressConfig": {"Name":"DomainAddressConfig","Docs":"","Fields":[{"Name":"LocalpartCatchallSeparator","Docs":"","Typewords":["string"]},{"Name":"LocalpartCaseSensitive","Docs":"","Typewords":["bool"]}]},
"EventViewErr": {"Name":"EventViewErr","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]},{"Name":"Err","Docs":"","Typewords":["string"]}]}, "EventViewErr": {"Name":"EventViewErr","Docs":"","Fields":[{"Name":"ViewID","Docs":"","Typewords":["int64"]},{"Name":"RequestID","Docs":"","Typewords":["int64"]},{"Name":"Err","Docs":"","Typewords":["string"]}]},

View file

@ -366,7 +366,12 @@ func TestAPI(t *testing.T) {
// RecipientSecurity // RecipientSecurity
resolver := dns.MockResolver{} resolver := dns.MockResolver{}
rs, err := recipientSecurity(ctxbg, resolver, "mjl@a.mox.example") rs, err := recipientSecurity(ctx, resolver, "mjl@a.mox.example")
tcompare(t, err, nil) tcompare(t, err, nil)
tcompare(t, rs, RecipientSecurity{SecurityResultNo, SecurityResultNo, SecurityResultNo}) tcompare(t, rs, RecipientSecurity{SecurityResultUnknown, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultUnknown})
err = acc.DB.Insert(ctx, &store.RecipientDomainTLS{Domain: "a.mox.example", STARTTLS: true, RequireTLS: false})
tcheck(t, err, "insert recipient domain tls info")
rs, err = recipientSecurity(ctx, resolver, "mjl@a.mox.example")
tcompare(t, err, nil)
tcompare(t, rs, RecipientSecurity{SecurityResultYes, SecurityResultNo, SecurityResultNo, SecurityResultNo, SecurityResultNo})
} }

View file

@ -62,11 +62,11 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },
"RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] },
"EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] },
"DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] },
"EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] },

View file

@ -62,11 +62,11 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },
"RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] },
"EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] },
"DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] },
"EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] },

View file

@ -62,11 +62,11 @@ var api;
"Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] }, "Address": { "Name": "Address", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Host", "Docs": "", "Typewords": ["string"] }] },
"MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] }, "MessageAddress": { "Name": "MessageAddress", "Docs": "", "Fields": [{ "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "User", "Docs": "", "Typewords": ["string"] }, { "Name": "Domain", "Docs": "", "Typewords": ["Domain"] }] },
"Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] }, "Domain": { "Name": "Domain", "Docs": "", "Fields": [{ "Name": "ASCII", "Docs": "", "Typewords": ["string"] }, { "Name": "Unicode", "Docs": "", "Typewords": ["string"] }] },
"SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }] }, "SubmitMessage": { "Name": "SubmitMessage", "Docs": "", "Fields": [{ "Name": "From", "Docs": "", "Typewords": ["string"] }, { "Name": "To", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Cc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Bcc", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "Subject", "Docs": "", "Typewords": ["string"] }, { "Name": "TextBody", "Docs": "", "Typewords": ["string"] }, { "Name": "Attachments", "Docs": "", "Typewords": ["[]", "File"] }, { "Name": "ForwardAttachments", "Docs": "", "Typewords": ["ForwardAttachments"] }, { "Name": "IsForward", "Docs": "", "Typewords": ["bool"] }, { "Name": "ResponseMessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "ReplyTo", "Docs": "", "Typewords": ["string"] }, { "Name": "UserAgent", "Docs": "", "Typewords": ["string"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["nullable", "bool"] }] },
"File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] }, "File": { "Name": "File", "Docs": "", "Fields": [{ "Name": "Filename", "Docs": "", "Typewords": ["string"] }, { "Name": "DataURI", "Docs": "", "Typewords": ["string"] }] },
"ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] }, "ForwardAttachments": { "Name": "ForwardAttachments", "Docs": "", "Fields": [{ "Name": "MessageID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Paths", "Docs": "", "Typewords": ["[]", "[]", "int32"] }] },
"Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] }, "Mailbox": { "Name": "Mailbox", "Docs": "", "Fields": [{ "Name": "ID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Name", "Docs": "", "Typewords": ["string"] }, { "Name": "UIDValidity", "Docs": "", "Typewords": ["uint32"] }, { "Name": "UIDNext", "Docs": "", "Typewords": ["UID"] }, { "Name": "Archive", "Docs": "", "Typewords": ["bool"] }, { "Name": "Draft", "Docs": "", "Typewords": ["bool"] }, { "Name": "Junk", "Docs": "", "Typewords": ["bool"] }, { "Name": "Sent", "Docs": "", "Typewords": ["bool"] }, { "Name": "Trash", "Docs": "", "Typewords": ["bool"] }, { "Name": "Keywords", "Docs": "", "Typewords": ["[]", "string"] }, { "Name": "HaveCounts", "Docs": "", "Typewords": ["bool"] }, { "Name": "Total", "Docs": "", "Typewords": ["int64"] }, { "Name": "Deleted", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unread", "Docs": "", "Typewords": ["int64"] }, { "Name": "Unseen", "Docs": "", "Typewords": ["int64"] }, { "Name": "Size", "Docs": "", "Typewords": ["int64"] }] },
"RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }] }, "RecipientSecurity": { "Name": "RecipientSecurity", "Docs": "", "Fields": [{ "Name": "STARTTLS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "MTASTS", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DNSSEC", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "DANE", "Docs": "", "Typewords": ["SecurityResult"] }, { "Name": "RequireTLS", "Docs": "", "Typewords": ["SecurityResult"] }] },
"EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] }, "EventStart": { "Name": "EventStart", "Docs": "", "Fields": [{ "Name": "SSEID", "Docs": "", "Typewords": ["int64"] }, { "Name": "LoginAddress", "Docs": "", "Typewords": ["MessageAddress"] }, { "Name": "Addresses", "Docs": "", "Typewords": ["[]", "MessageAddress"] }, { "Name": "DomainAddressConfigs", "Docs": "", "Typewords": ["{}", "DomainAddressConfig"] }, { "Name": "MailboxName", "Docs": "", "Typewords": ["string"] }, { "Name": "Mailboxes", "Docs": "", "Typewords": ["[]", "Mailbox"] }] },
"DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] }, "DomainAddressConfig": { "Name": "DomainAddressConfig", "Docs": "", "Fields": [{ "Name": "LocalpartCatchallSeparator", "Docs": "", "Typewords": ["string"] }, { "Name": "LocalpartCaseSensitive", "Docs": "", "Typewords": ["bool"] }] },
"EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] }, "EventViewErr": { "Name": "EventViewErr", "Docs": "", "Fields": [{ "Name": "ViewID", "Docs": "", "Typewords": ["int64"] }, { "Name": "RequestID", "Docs": "", "Typewords": ["int64"] }, { "Name": "Err", "Docs": "", "Typewords": ["string"] }] },
@ -1039,7 +1039,7 @@ To simulate slow API calls and SSE events:
Show additional headers of messages: Show additional headers of messages:
settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason']}) settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason', 'TLS-Required']})
Enable logging and reload afterwards: Enable logging and reload afterwards:
@ -1987,6 +1987,7 @@ const compose = (opts) => {
let subject; let subject;
let body; let body;
let attachments; let attachments;
let requiretls;
let toBtn, ccBtn, bccBtn, replyToBtn, customFromBtn; let toBtn, ccBtn, bccBtn, replyToBtn, customFromBtn;
let replyToCell, toCell, ccCell, bccCell; // Where we append new address views. let replyToCell, toCell, ccCell, bccCell; // Where we append new address views.
let toRow, replyToRow, ccRow, bccRow; // We show/hide rows as needed. let toRow, replyToRow, ccRow, bccRow; // We show/hide rows as needed.
@ -2035,6 +2036,7 @@ const compose = (opts) => {
ForwardAttachments: forwardAttachmentPaths.length === 0 ? { MessageID: 0, Paths: [] } : { MessageID: opts.attachmentsMessageItem.Message.ID, Paths: forwardAttachmentPaths }, ForwardAttachments: forwardAttachmentPaths.length === 0 ? { MessageID: 0, Paths: [] } : { MessageID: opts.attachmentsMessageItem.Message.ID, Paths: forwardAttachmentPaths },
IsForward: opts.isForward || false, IsForward: opts.isForward || false,
ResponseMessageID: opts.responseMessageID || 0, ResponseMessageID: opts.responseMessageID || 0,
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
}; };
await client.MessageSubmit(message); await client.MessageSubmit(message);
cmdCancel(); cmdCancel();
@ -2042,10 +2044,10 @@ const compose = (opts) => {
const cmdSend = async () => { const cmdSend = async () => {
await withStatus('Sending email', submit(), fieldset); await withStatus('Sending email', submit(), fieldset);
}; };
const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow); }; const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow); };
const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow); }; const cmdAddCc = async () => { newAddrView('', true, ccViews, ccBtn, ccCell, ccRow); };
const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow); }; const cmdAddBcc = async () => { newAddrView('', true, bccViews, bccBtn, bccCell, bccRow); };
const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true); }; const cmdReplyTo = async () => { newAddrView('', false, replytoViews, replyToBtn, replyToCell, replyToRow, true); };
const cmdCustomFrom = async () => { const cmdCustomFrom = async () => {
if (customFrom) { if (customFrom) {
return; return;
@ -2063,7 +2065,7 @@ const compose = (opts) => {
'ctrl Y': cmdReplyTo, 'ctrl Y': cmdReplyTo,
// ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add. // ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
}; };
const newAddrView = (addr, views, btn, cell, row, single) => { const newAddrView = (addr, isRecipient, views, btn, cell, row, single) => {
if (single && views.length !== 0) { if (single && views.length !== 0) {
return; return;
} }
@ -2096,11 +2098,13 @@ const compose = (opts) => {
} }
return '#aaa'; return '#aaa';
}; };
const setBar = (c0, c1, c2) => { const setBar = (c0, c1, c2, c3, c4) => {
const stops = [ const stops = [
c0 + ' 0%', c0 + ' 32%', 'white 32%', 'white 33%', c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%',
c1 + ' 33%', c1 + ' 66%', 'white 66%', 'white 67%', c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%',
c2 + ' 67%', c2 + ' 100%', c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%',
c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%',
c4 + ' 80%', c4 + ' 100%',
].join(', '); ].join(', ');
securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1'; securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1';
}; };
@ -2108,19 +2112,56 @@ const compose = (opts) => {
rcptSecAborter = aborter; rcptSecAborter = aborter;
rcptSecPromise = client.withOptions({ aborter: aborter }).RecipientSecurity(inputElem.value); rcptSecPromise = client.withOptions({ aborter: aborter }).RecipientSecurity(inputElem.value);
rcptSecPromise.then((rs) => { rcptSecPromise.then((rs) => {
setBar(color(rs.MTASTS), color(rs.DNSSEC), color(rs.DANE)); setBar(color(rs.STARTTLS), color(rs.MTASTS), color(rs.DNSSEC), color(rs.DANE), color(rs.RequireTLS));
const implemented = [];
const check = (v, s) => {
if (v) {
implemented.push(s);
}
};
check(rs.STARTTLS === api.SecurityResult.SecurityResultYes, 'STARTTLS');
check(rs.MTASTS === api.SecurityResult.SecurityResultYes, 'MTASTS');
check(rs.DNSSEC === api.SecurityResult.SecurityResultYes, 'DNSSEC');
check(rs.DANE === api.SecurityResult.SecurityResultYes, 'DANE');
check(rs.RequireTLS === api.SecurityResult.SecurityResultYes, 'RequireTLS');
const status = 'Security mechanisms known to be implemented by the recipient domain: ' + (implemented.length === 0 ? '(none)' : implemented.join(', ')) + '.';
inputElem.setAttribute('title', status + '\n\n' + recipientSecurityTitle);
aborter.abort = undefined; aborter.abort = undefined;
v.recipientSecurity = rs;
if (isRecipient) {
// If all recipients implement REQUIRETLS, we can enable it.
let reqtls = true;
const walk = (l) => {
for (const v of l) {
if (v.recipientSecurity?.RequireTLS !== api.SecurityResult.SecurityResultYes) {
reqtls = false;
break;
}
}
};
walk(toViews);
walk(ccViews);
walk(bccViews);
if (requiretls.value === '' || requiretls.value === 'yes') {
requiretls.value = reqtls ? 'yes' : '';
}
}
}, () => { }, () => {
setBar('#888', '#888', '#888'); setBar('#888', '#888', '#888', '#888', '#888');
inputElem.setAttribute('title', 'Error fetching security mechanisms known to be implemented by the recipient domain...\n\n' + recipientSecurityTitle);
aborter.abort = undefined; aborter.abort = undefined;
if (requiretls.value === 'yes') {
requiretls.value = '';
}
}); });
}; };
const root = dom.span(autosizeElem = dom.span(dom._class('autosize'), inputElem = dom.input(focusPlaceholder('Jane <jane@example.org>'), style({ width: 'auto' }), attr.value(addr), newAddressComplete(), attr.title('The bars below the input field indicate security features of the recipient (domain):\n1. Delivery with STARTTLS and MTA-STS (PKIX/WebPKI) enforced.\n2. MX lookup resulted in DNSSEC-signed response.\n3. First delivery destination host has DANE, so STARTTLS is required.\n\nColors:\n- Red, not implemented/unsupported\n- Green, implemented/supported\n- Gray, error while determining\n- Absent/white, unknown or skipped (e.g. dane check skipped due to dnssec-lookup error)'), function keydown(e) { const recipientSecurityTitle = 'Description of security mechanisms recipient domains may implement:\n1. STARTTLS: Opportunistic (unverified) TLS with STARTTLS, successfully negotiated during the most recent delivery attempt.\n2. MTA-STS: For PKIX/WebPKI-verified TLS.\n3. DNSSEC: MX DNS records are DNSSEC-signed.\n4. DANE: First delivery destination host implements DANE for verified TLS.\n5. RequireTLS: SMTP extension for verified TLS delivery into recipient mailbox, support detected during the most recent delivery attempt.\n\nChecks STARTTLS, DANE and RequireTLS cover the most recently used delivery path, not necessarily all possible delivery paths.\n\nThe bars below the input field indicate implementation status by the recipient domain:\n- Red, not implemented/unsupported\n- Green, implemented/supported\n- Gray, error while determining\n- Absent/white, unknown or skipped (e.g. no previous delivery attempt, or DANE check skipped due to DNSSEC-lookup error)';
const root = dom.span(autosizeElem = dom.span(dom._class('autosize'), inputElem = dom.input(focusPlaceholder('Jane <jane@example.org>'), style({ width: 'auto' }), attr.value(addr), newAddressComplete(), attr.title(recipientSecurityTitle), function keydown(e) {
if (e.key === '-' && e.ctrlKey) { if (e.key === '-' && e.ctrlKey) {
remove(); remove();
} }
else if (e.key === '=' && e.ctrlKey) { else if (e.key === '=' && e.ctrlKey) {
newAddrView('', views, btn, cell, row, single); newAddrView('', isRecipient, views, btn, cell, row, single);
} }
else { else {
return; return;
@ -2143,7 +2184,6 @@ const compose = (opts) => {
} }
}), ' '); }), ' ');
autosizeElem.dataset.value = inputElem.value; autosizeElem.dataset.value = inputElem.value;
fetchRecipientSecurity();
const remove = () => { const remove = () => {
const i = views.indexOf(v); const i = views.indexOf(v);
views.splice(i, 1); views.splice(i, 1);
@ -2170,7 +2210,8 @@ const compose = (opts) => {
next.focus(); next.focus();
} }
}; };
const v = { root: root, input: inputElem }; const v = { root: root, input: inputElem, isRecipient: isRecipient, recipientSecurity: null };
fetchRecipientSecurity();
views.push(v); views.push(v);
cell.appendChild(v.root); cell.appendChild(v.root);
row.style.display = ''; row.style.display = '';
@ -2255,16 +2296,16 @@ const compose = (opts) => {
return v; return v;
}), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) { }), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) {
forwardAttachmentViews.forEach(v => v.checkbox.checked = e.target.checked); forwardAttachmentViews.forEach(v => v.checkbox.checked = e.target.checked);
}), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.div(style({ margin: '1ex 0' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.submitbutton('Send')), async function submit(e) { }), ' (Toggle all)')), noAttachmentsWarning = dom.div(style({ display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0' }), 'Message mentions attachments, but no files are attached.'), dom.label(style({ margin: '1ex 0', display: 'block' }), 'Attachments ', attachments = dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments(); })), dom.label(style({ margin: '1ex 0', display: 'block' }), attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'), 'TLS ', requiretls = dom.select(dom.option(attr.value(''), 'Default'), dom.option(attr.value('yes'), 'With RequireTLS'), dom.option(attr.value('no'), 'Fallback to insecure'))), dom.div(style({ margin: '3ex 0 1ex 0', display: 'block' }), dom.submitbutton('Send'))), async function submit(e) {
e.preventDefault(); e.preventDefault();
shortcutCmd(cmdSend, shortcuts); shortcutCmd(cmdSend, shortcuts);
})); }));
subjectAutosize.dataset.value = subject.value; subjectAutosize.dataset.value = subject.value;
(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)); (opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, true, toViews, toBtn, toCell, toRow));
(opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)); (opts.cc || []).forEach(s => newAddrView(s, true, ccViews, ccBtn, ccCell, ccRow));
(opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)); (opts.bcc || []).forEach(s => newAddrView(s, true, bccViews, bccBtn, bccCell, bccRow));
if (opts.replyto) { if (opts.replyto) {
newAddrView(opts.replyto, replytoViews, replyToBtn, replyToCell, replyToRow, true); newAddrView(opts.replyto, false, replytoViews, replyToBtn, replyToCell, replyToRow, true);
} }
if (!opts.cc || !opts.cc.length) { if (!opts.cc || !opts.cc.length) {
ccRow.style.display = 'none'; ccRow.style.display = 'none';

View file

@ -52,7 +52,7 @@ To simulate slow API calls and SSE events:
Show additional headers of messages: Show additional headers of messages:
settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason']}) settingsPut({...settings, showHeaders: ['User-Agent', 'X-Mailer', 'Message-Id', 'List-Id', 'List-Post', 'X-Mox-Reason', 'TLS-Required']})
Enable logging and reload afterwards: Enable logging and reload afterwards:
@ -1154,6 +1154,8 @@ const compose = (opts: ComposeOptions) => {
type AddrView = { type AddrView = {
root: HTMLElement root: HTMLElement
input: HTMLInputElement input: HTMLInputElement
isRecipient: boolean
recipientSecurity: null | api.RecipientSecurity
} }
let fieldset: HTMLFieldSetElement let fieldset: HTMLFieldSetElement
@ -1163,6 +1165,7 @@ const compose = (opts: ComposeOptions) => {
let subject: HTMLInputElement let subject: HTMLInputElement
let body: HTMLTextAreaElement let body: HTMLTextAreaElement
let attachments: HTMLInputElement let attachments: HTMLInputElement
let requiretls: HTMLSelectElement
let toBtn: HTMLButtonElement, ccBtn: HTMLButtonElement, bccBtn: HTMLButtonElement, replyToBtn: HTMLButtonElement, customFromBtn: HTMLButtonElement let toBtn: HTMLButtonElement, ccBtn: HTMLButtonElement, bccBtn: HTMLButtonElement, replyToBtn: HTMLButtonElement, customFromBtn: HTMLButtonElement
let replyToCell: HTMLElement, toCell: HTMLElement, ccCell: HTMLElement, bccCell: HTMLElement // Where we append new address views. let replyToCell: HTMLElement, toCell: HTMLElement, ccCell: HTMLElement, bccCell: HTMLElement // Where we append new address views.
@ -1217,6 +1220,7 @@ const compose = (opts: ComposeOptions) => {
ForwardAttachments: forwardAttachmentPaths.length === 0 ? {MessageID: 0, Paths: []} : {MessageID: opts.attachmentsMessageItem!.Message.ID, Paths: forwardAttachmentPaths}, ForwardAttachments: forwardAttachmentPaths.length === 0 ? {MessageID: 0, Paths: []} : {MessageID: opts.attachmentsMessageItem!.Message.ID, Paths: forwardAttachmentPaths},
IsForward: opts.isForward || false, IsForward: opts.isForward || false,
ResponseMessageID: opts.responseMessageID || 0, ResponseMessageID: opts.responseMessageID || 0,
RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes',
} }
await client.MessageSubmit(message) await client.MessageSubmit(message)
cmdCancel() cmdCancel()
@ -1226,10 +1230,10 @@ const compose = (opts: ComposeOptions) => {
await withStatus('Sending email', submit(), fieldset) await withStatus('Sending email', submit(), fieldset)
} }
const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow) } const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow) }
const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow) } const cmdAddCc = async () => { newAddrView('', true, ccViews, ccBtn, ccCell, ccRow) }
const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow) } const cmdAddBcc = async () => { newAddrView('', true, bccViews, bccBtn, bccCell, bccRow) }
const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true) } const cmdReplyTo = async () => { newAddrView('', false, replytoViews, replyToBtn, replyToCell, replyToRow, true) }
const cmdCustomFrom = async () => { const cmdCustomFrom = async () => {
if (customFrom) { if (customFrom) {
return return
@ -1249,7 +1253,7 @@ const compose = (opts: ComposeOptions) => {
// ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add. // ctrl - and ctrl = (+) not included, they are handled by keydown handlers on in the inputs they remove/add.
} }
const newAddrView = (addr: string, views: AddrView[], btn: HTMLButtonElement, cell: HTMLElement, row: HTMLElement, single?: boolean) => { const newAddrView = (addr: string, isRecipient: boolean, views: AddrView[], btn: HTMLButtonElement, cell: HTMLElement, row: HTMLElement, single?: boolean) => {
if (single && views.length !== 0) { if (single && views.length !== 0) {
return return
} }
@ -1285,11 +1289,13 @@ const compose = (opts: ComposeOptions) => {
} }
return '#aaa' return '#aaa'
} }
const setBar = (c0: string, c1: string, c2: string) => { const setBar = (c0: string, c1: string, c2: string, c3: string, c4: string) => {
const stops = [ const stops = [
c0 + ' 0%', c0 + ' 32%', 'white 32%', 'white 33%', c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%',
c1 + ' 33%', c1 + ' 66%', 'white 66%', 'white 67%', c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%',
c2 + ' 67%', c2 + ' 100%', c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%',
c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%',
c4 + ' 80%', c4 + ' 100%',
].join(', ') ].join(', ')
securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1' securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1'
} }
@ -1298,14 +1304,53 @@ const compose = (opts: ComposeOptions) => {
rcptSecAborter = aborter rcptSecAborter = aborter
rcptSecPromise = client.withOptions({aborter: aborter}).RecipientSecurity(inputElem.value) rcptSecPromise = client.withOptions({aborter: aborter}).RecipientSecurity(inputElem.value)
rcptSecPromise.then((rs) => { rcptSecPromise.then((rs) => {
setBar(color(rs.MTASTS), color(rs.DNSSEC), color(rs.DANE)) setBar(color(rs.STARTTLS), color(rs.MTASTS), color(rs.DNSSEC), color(rs.DANE), color(rs.RequireTLS))
const implemented: string[] = []
const check = (v: boolean, s: string) => {
if (v) {
implemented.push(s)
}
}
check(rs.STARTTLS === api.SecurityResult.SecurityResultYes, 'STARTTLS')
check(rs.MTASTS === api.SecurityResult.SecurityResultYes, 'MTASTS')
check(rs.DNSSEC === api.SecurityResult.SecurityResultYes, 'DNSSEC')
check(rs.DANE === api.SecurityResult.SecurityResultYes, 'DANE')
check(rs.RequireTLS === api.SecurityResult.SecurityResultYes, 'RequireTLS')
const status = 'Security mechanisms known to be implemented by the recipient domain: '+ (implemented.length === 0 ? '(none)' : implemented.join(', ')) + '.'
inputElem.setAttribute('title', status+'\n\n'+recipientSecurityTitle)
aborter.abort = undefined aborter.abort = undefined
v.recipientSecurity = rs
if (isRecipient) {
// If all recipients implement REQUIRETLS, we can enable it.
let reqtls = true
const walk = (l: AddrView[]) => {
for (const v of l) {
if (v.recipientSecurity?.RequireTLS !== api.SecurityResult.SecurityResultYes) {
reqtls = false
break
}
}
}
walk(toViews)
walk(ccViews)
walk(bccViews)
if (requiretls.value === '' || requiretls.value === 'yes') {
requiretls.value = reqtls ? 'yes' : ''
}
}
}, () => { }, () => {
setBar('#888', '#888', '#888') setBar('#888', '#888', '#888', '#888', '#888')
inputElem.setAttribute('title', 'Error fetching security mechanisms known to be implemented by the recipient domain...\n\n'+recipientSecurityTitle)
aborter.abort = undefined aborter.abort = undefined
if (requiretls.value === 'yes') {
requiretls.value = ''
}
}) })
} }
const recipientSecurityTitle = 'Description of security mechanisms recipient domains may implement:\n1. STARTTLS: Opportunistic (unverified) TLS with STARTTLS, successfully negotiated during the most recent delivery attempt.\n2. MTA-STS: For PKIX/WebPKI-verified TLS.\n3. DNSSEC: MX DNS records are DNSSEC-signed.\n4. DANE: First delivery destination host implements DANE for verified TLS.\n5. RequireTLS: SMTP extension for verified TLS delivery into recipient mailbox, support detected during the most recent delivery attempt.\n\nChecks STARTTLS, DANE and RequireTLS cover the most recently used delivery path, not necessarily all possible delivery paths.\n\nThe bars below the input field indicate implementation status by the recipient domain:\n- Red, not implemented/unsupported\n- Green, implemented/supported\n- Gray, error while determining\n- Absent/white, unknown or skipped (e.g. no previous delivery attempt, or DANE check skipped due to DNSSEC-lookup error)'
const root = dom.span( const root = dom.span(
autosizeElem=dom.span( autosizeElem=dom.span(
dom._class('autosize'), dom._class('autosize'),
@ -1314,12 +1359,12 @@ const compose = (opts: ComposeOptions) => {
style({width: 'auto'}), style({width: 'auto'}),
attr.value(addr), attr.value(addr),
newAddressComplete(), newAddressComplete(),
attr.title('The bars below the input field indicate security features of the recipient (domain):\n1. Delivery with STARTTLS and MTA-STS (PKIX/WebPKI) enforced.\n2. MX lookup resulted in DNSSEC-signed response.\n3. First delivery destination host has DANE, so STARTTLS is required.\n\nColors:\n- Red, not implemented/unsupported\n- Green, implemented/supported\n- Gray, error while determining\n- Absent/white, unknown or skipped (e.g. dane check skipped due to dnssec-lookup error)'), attr.title(recipientSecurityTitle),
function keydown(e: KeyboardEvent) { function keydown(e: KeyboardEvent) {
if (e.key === '-' && e.ctrlKey) { if (e.key === '-' && e.ctrlKey) {
remove() remove()
} else if (e.key === '=' && e.ctrlKey) { } else if (e.key === '=' && e.ctrlKey) {
newAddrView('', views, btn, cell, row, single) newAddrView('', isRecipient, views, btn, cell, row, single)
} else { } else {
return return
} }
@ -1353,7 +1398,6 @@ const compose = (opts: ComposeOptions) => {
' ', ' ',
) )
autosizeElem.dataset.value = inputElem.value autosizeElem.dataset.value = inputElem.value
fetchRecipientSecurity()
const remove = () => { const remove = () => {
const i = views.indexOf(v) const i = views.indexOf(v)
@ -1383,7 +1427,10 @@ const compose = (opts: ComposeOptions) => {
} }
} }
const v: AddrView = {root: root, input: inputElem} const v: AddrView = {root: root, input: inputElem, isRecipient: isRecipient, recipientSecurity: null}
fetchRecipientSecurity()
views.push(v) views.push(v)
cell.appendChild(v.root) cell.appendChild(v.root)
row.style.display = '' row.style.display = ''
@ -1541,8 +1588,21 @@ const compose = (opts: ComposeOptions) => {
}), ' (Toggle all)') }), ' (Toggle all)')
), ),
noAttachmentsWarning=dom.div(style({display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0'}), 'Message mentions attachments, but no files are attached.'), noAttachmentsWarning=dom.div(style({display: 'none', backgroundColor: '#fcd284', padding: '0.15em .25em', margin: '.5em 0'}), 'Message mentions attachments, but no files are attached.'),
dom.div(style({margin: '1ex 0'}), 'Attachments ', attachments=dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments() })), dom.label(style({margin: '1ex 0', display: 'block'}), 'Attachments ', attachments=dom.input(attr.type('file'), attr.multiple(''), function change() { checkAttachments() })),
dom.submitbutton('Send'), dom.label(
style({margin: '1ex 0', display: 'block'}),
attr.title('How to use TLS for message delivery over SMTP:\n\nDefault: Delivery attempts follow the policies published by the recipient domain: Verification with MTA-STS and/or DANE, or optional opportunistic unverified STARTTLS if the domain does not specify a policy.\n\nWith RequireTLS: For sensitive messages, you may want to require verified TLS. The recipient destination domain SMTP server must support the REQUIRETLS SMTP extension for delivery to succeed. It is automatically chosen when the destination domain mail servers of all recipients are known to support it.\n\nFallback to insecure: If delivery fails due to MTA-STS and/or DANE policies specified by the recipient domain, and the content is not sensitive, you may choose to ignore the recipient domain TLS policies so delivery can succeed.'),
'TLS ',
requiretls=dom.select(
dom.option(attr.value(''), 'Default'),
dom.option(attr.value('yes'), 'With RequireTLS'),
dom.option(attr.value('no'), 'Fallback to insecure'),
),
),
dom.div(
style({margin: '3ex 0 1ex 0', display: 'block'}),
dom.submitbutton('Send'),
),
), ),
async function submit(e: SubmitEvent) { async function submit(e: SubmitEvent) {
e.preventDefault() e.preventDefault()
@ -1553,11 +1613,11 @@ const compose = (opts: ComposeOptions) => {
subjectAutosize.dataset.value = subject.value subjectAutosize.dataset.value = subject.value
;(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)) ;(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, true, toViews, toBtn, toCell, toRow))
;(opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)) ;(opts.cc || []).forEach(s => newAddrView(s,true, ccViews, ccBtn, ccCell, ccRow))
;(opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)) ;(opts.bcc || []).forEach(s => newAddrView(s, true, bccViews, bccBtn, bccCell, bccRow))
if (opts.replyto) { if (opts.replyto) {
newAddrView(opts.replyto, replytoViews, replyToBtn, replyToCell, replyToRow, true) newAddrView(opts.replyto, false, replytoViews, replyToBtn, replyToCell, replyToRow, true)
} }
if (!opts.cc || !opts.cc.length) { if (!opts.cc || !opts.cc.length) {
ccRow.style.display = 'none' ccRow.style.display = 'none'