diff --git a/README.md b/README.md index 7101401128..b61aa65d3f 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ See Quickstart below to get started. ("localparts"), and in domain names (IDNA). - 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, - with incoming TLSRPT reporting. + including REQUIRETLS and with incoming TLSRPT reporting. - Web admin interface that helps you set up your domains and accounts (instructions to create DNS records, configure SPF/DKIM/DMARC/TLSRPT/MTA-STS), for status information, managing diff --git a/config/config.go b/config/config.go index c98108b838..216138927b 100644 --- a/config/config.go +++ b/config/config.go @@ -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."` SMTP struct { Enabled bool - Port int `sconf:"optional" sconf-doc:"Default 25."` - 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."` - 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."` + Port int `sconf:"optional" sconf-doc:"Default 25."` + 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."` + 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."` diff --git a/config/doc.go b/config/doc.go index feead06150..e1cfbf0069 100644 --- a/config/doc.go +++ b/config/doc.go @@ -184,6 +184,13 @@ describe-static" and "mox config describe-domains": # TLS and may not be able to deliver messages. (optional) 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 # consulted for connections/messages without enough reputation to make an # accept/reject decision. This prevents sending IPs of all communications to the diff --git a/dane/dane.go b/dane/dane.go index 1eddde0a01..7e140ca6d3 100644 --- a/dane/dane.go +++ b/dane/dane.go @@ -297,7 +297,7 @@ func Dial(ctx context.Context, resolver dns.Resolver, network, address string, a // verified, if any. func TLSClientConfig(log *mlog.Log, records []adns.TLSA, allowedHost dns.Domain, moreAllowedHosts []dns.Domain, verifiedRecord *adns.TLSA) tls.Config { return tls.Config{ - ServerName: allowedHost.ASCII, + ServerName: allowedHost.ASCII, // For SNI. InsecureSkipVerify: true, VerifyConnection: func(cs tls.ConnectionState) error { verified, record, err := Verify(log, records, cs, allowedHost, moreAllowedHosts) diff --git a/gentestdata.go b/gentestdata.go index 845a319c85..d3c5496785 100644 --- a/gentestdata.go +++ b/gentestdata.go @@ -233,7 +233,7 @@ Accounts: const qmsg = "From: \r\nTo: \r\nSubject: test\r\n\r\nthe message...\r\n" _, err = fmt.Fprint(mf, qmsg) xcheckf(err, "writing message") - _, err = queue.Add(ctxbg, log, "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "", prefix, mf, nil) + _, err = queue.Add(ctxbg, log, "test0", mailfrom, rcptto, false, false, int64(len(qmsg)), "", prefix, mf, nil, nil) xcheckf(err, "enqueue message") // Create three accounts. diff --git a/integration_test.go b/integration_test.go index 69941114fd..b07199ac69 100644 --- a/integration_test.go +++ b/integration_test.go @@ -131,7 +131,7 @@ This is the message. 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) 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") err = c.Close() tcheck(t, err, "close smtpclient") diff --git a/main.go b/main.go index 23aa93dd1e..b18c38c71b 100644 --- a/main.go +++ b/main.go @@ -130,6 +130,7 @@ var commands = []struct { {"cid", cmdCid}, {"clientconfig", cmdClientConfig}, {"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 dialmx", cmdDANEDialmx}, {"dane makerecord", cmdDANEMakeRecord}, diff --git a/queue/direct.go b/queue/direct.go index ebcdf0a069..5199ce80c9 100644 --- a/queue/direct.go +++ b/queue/direct.go @@ -23,6 +23,7 @@ import ( "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/mtasts" "github.com/mjl-/mox/mtastsdb" + "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtpclient" "github.com/mjl-/mox/store" ) @@ -58,13 +59,36 @@ var ( 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? 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 { 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 { 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)) retryUntil := m.LastAttempt.Add((4 + 8 + 16) * time.Hour) - queueDSNDelay(qlog, m, remoteMTA, secodeOpt, errmsg, retryUntil) + deliverDSNDelay(qlog, m, remoteMTA, secodeOpt, errmsg, retryUntil) } else { 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: // - 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. + // - 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. // 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) policy, _, err = mtastsdb.Get(cidctx, resolver, origNextHop) if err != nil { - qlog.Infox("mtasts lookup temporary error, aborting delivery attempt", err, mlog.Field("domain", origNextHop)) - fail(qlog, m, backoff, false, dsn.NameIP{}, "", err.Error()) - return + if m.RequireTLS != nil && !*m.RequireTLS { + qlog.Infox("mtasts lookup temporary error, continuing due to tls-required-no message header", err, mlog.Field("domain", origNextHop)) + 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 // 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 secodeOpt, errmsg string 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 for _, h := range hosts { 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()) } 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, ",")) - qlog.Error("mx host does not match mta-sts policy in mode enforce, skipping", mlog.Field("host", h.Domain), mlog.Field("policyhosts", policyHosts)) - continue + if m.RequireTLS != nil && !*m.RequireTLS { + 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)) + 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)) @@ -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 // that opportunistic TLS does not do regular certificate verification, so that can't // 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 // 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 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 { break } + if secodeOpt == smtp.SePol7MissingReqTLS { + nmissingRequireTLS++ + } } // 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. // 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) } // deliverHost attempts to deliver m to host. Depending on tlsMode, we'll do // required TLS with WebPKI verification (with MTA-STS), opportunistic DANE TLS -// (opportunistic TLS) or non-verifying TLS (opportunistic TLS) deliverHost updates -// m.DialedIPs, which must be saved in case of failure to deliver. +// (opportunistic TLS), non-verifying TLS (opportunistic TLS) or skip TLS +// 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 // 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() { 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 { 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: // Fallback mode for DANE without usable records, so skip DANE. + // We shouldn't be able to get here, but no harm handling it. default: // Look for TLSA records in either the expandedHost, or otherwise the original // host. ../rfc/7672:912 @@ -324,6 +384,11 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer, } else if !daneRequired { log.Debugx("not doing opportunistic dane after gathering tlsa records", err) 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. } @@ -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)) } + // 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. var conn net.Conn if err == nil { @@ -380,6 +455,9 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer, moreHosts = tlsRemoteHostnames[1:] } 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) defer func() { if sc == nil { @@ -389,6 +467,21 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer, } 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 { // SMTP session is ready. Finally try to actually deliver. has8bit := m.Has8bit @@ -401,7 +494,7 @@ func deliverHost(log *mlog.Log, resolver dns.Resolver, dialer smtpclient.Dialer, size = int64(len(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 { 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.") { 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 { 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 +} diff --git a/queue/dsn.go b/queue/dsn.go index 730efe6fe6..9f4bd9d683 100644 --- a/queue/dsn.go +++ b/queue/dsn.go @@ -15,7 +15,7 @@ import ( "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" message := fmt.Sprintf(` Delivery has failed permanently for your email to: @@ -29,10 +29,10 @@ Error during the last delivery attempt: %s `, 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" message := fmt.Sprintf(` Delivery has been delayed of your email to: @@ -47,15 +47,14 @@ Error during the last delivery attempt: %s `, 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 // users. So we are delivering to local users. ../rfc/5321:1466 // ../rfc/5321:1494 // ../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 queueDSN(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, permanent bool, retryUntil *time.Time, subject, textBody string) { +func deliverDSN(log *mlog.Log, m Msg, remoteMTA dsn.NameIP, secodeOpt, errmsg string, permanent bool, retryUntil *time.Time, subject, textBody string) { kind := "delayed delivery" if permanent { kind = "failure" diff --git a/queue/queue.go b/queue/queue.go index 420f9362a1..70efce3a72 100644 --- a/queue/queue.go +++ b/queue/queue.go @@ -99,6 +99,21 @@ type Msg struct { // admin interface. If empty (the default for a submitted message), regular routing // rules apply. 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. @@ -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 // server supports SMTPUTF8. If the remote SMTP server does not support SMTPUTF8, // 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 if Localserve { @@ -221,7 +236,7 @@ func Add(ctx context.Context, log *mlog.Log, senderAccount string, mailFrom, rcp }() 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 { return 0, err @@ -339,6 +354,18 @@ func Drop(ctx context.Context, ID int64, toDomain string, recipient string) (int 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 { io.ReadCloser io.ReaderAt diff --git a/queue/queue_test.go b/queue/queue_test.go index 134183d63a..c7e3f2f609 100644 --- a/queue/queue_test.go +++ b/queue/queue_test.go @@ -14,6 +14,7 @@ import ( "net" "os" "path/filepath" + "reflect" "strings" "testing" "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()) { // 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.ConfigStaticPath = filepath.FromSlash("../testdata/queue/mox.conf") mox.MustLoadConfig(true, false) @@ -91,10 +110,10 @@ func TestQueue(t *testing.T) { defer os.Remove(mf.Name()) defer mf.Close() - _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") - _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") msgs, err = List(ctxbg) @@ -198,6 +217,10 @@ func TestQueue(t *testing.T) { smtpdone := make(chan struct{}) 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. fmt.Fprintf(server, "220 mox.example\r\n") br := bufio.NewReader(server) @@ -225,14 +248,16 @@ func TestQueue(t *testing.T) { writeline("250 ok") readline("quit") writeline("221 ok") - - smtpdone <- struct{}{} } 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 return func(server net.Conn) { + defer func() { + smtpdone <- struct{}{} + }() + attempt++ // 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) readline("ehlo") - writeline("250 mox.example") + if requiretls { + writeline("250-mox.example") + writeline("250 requiretls") + } else { + writeline("250 mox.example") + } } readline("mail") writeline("250 ok") @@ -277,17 +307,19 @@ func TestQueue(t *testing.T) { writeline("250 ok") readline("quit") writeline("221 ok") - - smtpdone <- struct{}{} } } - fakeSMTPSTARTTLSServer := makeFakeSMTPSTARTTLSServer(&goodTLSConfig, 0) - makeBadFakeSMTPSTARTTLSServer := func() func(server net.Conn) { - return makeFakeSMTPSTARTTLSServer(&tls.Config{MaxVersion: tls.VersionTLS10, Certificates: []tls.Certificate{moxCert}}, 1) + fakeSMTPSTARTTLSServer := makeFakeSMTPSTARTTLSServer(&goodTLSConfig, 0, true) + makeBadFakeSMTPSTARTTLSServer := func(requiretls bool) func(server net.Conn) { + return makeFakeSMTPSTARTTLSServer(&tls.Config{MaxVersion: tls.VersionTLS10, Certificates: []tls.Certificate{moxCert}}, 1, requiretls) } 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. fmt.Fprintf(server, "220 mox.example\r\n") br := bufio.NewReader(server) @@ -307,11 +339,9 @@ func TestQueue(t *testing.T) { fmt.Fprintf(server, "250 ok\r\n") br.ReadString('\n') // Should be QUIT. 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() var pipes []net.Conn @@ -346,6 +376,12 @@ func TestQueue(t *testing.T) { 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() { t.Helper() timer.Reset(time.Second) @@ -358,6 +394,14 @@ func TestQueue(t *testing.T) { xmsgs, err := List(ctxbg) tcheck(t, err, "list queue") 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 } i++ @@ -379,6 +423,14 @@ func TestQueue(t *testing.T) { waitDeliver() 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. wasNetDialer := testDeliver(fakeSMTPServer) @@ -388,7 +440,7 @@ func TestQueue(t *testing.T) { // 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"}}} - _, err = Add(ctxbg, xlog, "mjl", path, topath, false, false, int64(len(testmsg)), "", nil, mf, nil) + _, err = Add(ctxbg, xlog, "mjl", path, topath, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") wasNetDialer = testDeliver(fakeSubmitServer) 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. - msgID, err := Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err := Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") transportSubmitTLS := "submittls" n, err = Kick(ctxbg, msgID, "", "", &transportSubmitTLS) @@ -420,7 +472,7 @@ func TestQueue(t *testing.T) { } // Add a message to be delivered with socks. - msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") transportSocks := "socks" n, err = Kick(ctxbg, msgID, "", "", &transportSocks) @@ -434,7 +486,7 @@ func TestQueue(t *testing.T) { } // Add message to be delivered with opportunistic TLS verification. - msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") n, err = Kick(ctxbg, msgID, "", "", nil) tcheck(t, err, "kick queue") @@ -444,14 +496,14 @@ func TestQueue(t *testing.T) { testDeliver(fakeSMTPSTARTTLSServer) // Test fallback to plain text with TLS handshake fails. - msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", 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(makeBadFakeSMTPSTARTTLSServer()) + testDeliver(makeBadFakeSMTPSTARTTLSServer(true)) // Add message to be delivered with DANE verification. 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}, }, } - msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", 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)), "", nil, mf, nil, &yes) tcheck(t, err, "add message to queue for delivery") n, err = Kick(ctxbg, msgID, "", "", nil) 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)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") n, err = Kick(ctxbg, msgID, "", "", nil) 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)}, }, } - msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + msgID, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", 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(makeBadFakeSMTPSTARTTLSServer()) + testDeliver(makeBadFakeSMTPSTARTTLSServer(true)) 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)), "", 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)), "", 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)), "", 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. resolver.AllAuthentic = false 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)), "", 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. - _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") msgs, err = List(ctxbg) @@ -666,7 +788,7 @@ func TestQueueStart(t *testing.T) { mf := prepareFile(t) defer os.Remove(mf.Name()) defer mf.Close() - _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil) + _, err = Add(ctxbg, xlog, "mjl", path, path, false, false, int64(len(testmsg)), "", nil, mf, nil, nil) tcheck(t, err, "add message to queue for delivery") checkDialed(true) diff --git a/queue/submit.go b/queue/submit.go index 346e9f7d59..2c78e536af 100644 --- a/queue/submit.go +++ b/queue/submit.go @@ -16,6 +16,7 @@ import ( "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "github.com/mjl-/mox/sasl" + "github.com/mjl-/mox/smtp" "github.com/mjl-/mox/smtpclient" "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))) }() - // We don't have to attempt SMTP-DANE for submission, since it only applies to SMTP - // relaying on port 25. ../rfc/7672:1261 + // todo: SMTP-DANE should be used when relaying on port 25. + // ../rfc/7672:1261 // 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) defer dialcancel() 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) 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 { qlog.Infox("delivery failed", err) } diff --git a/sendmail.go b/sendmail.go index ec6ec0c6c0..49d4c40e96 100644 --- a/sendmail.go +++ b/sendmail.go @@ -3,6 +3,7 @@ package main import ( "bufio" "context" + "errors" "fmt" "io" "log" @@ -23,18 +24,27 @@ import ( ) var submitconf struct { - 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.."` - 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."` - 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."` - 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."` - 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)."` + 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.."` + 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."` + 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."` + 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."` + 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)."` + 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) { c.params = ">/etc/moxsubmit.conf" c.help = `Describe configuration for mox when invoked as sendmail.` @@ -157,6 +167,9 @@ binary should be setgid that group: if !haveTo { line = fmt.Sprintf("To: <%s>\r\n", recipient) + line } + if submitconf.RequireTLS == RequireTLSNo { + line = "TLS-Required: No\r\n" + line + } header = false } else if header { t := strings.SplitN(line, ":", 2) @@ -199,6 +212,9 @@ binary should be setgid that group: break } } + if header && submitconf.RequireTLS == RequireTLSNo { + sb.WriteString("TLS-Required: No\r\n") + } msg := sb.String() if recipient == "" { @@ -262,6 +278,8 @@ binary should be setgid that group: tlsMode = smtpclient.TLSStrictImmediate } else if submitconf.STARTTLS { 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) @@ -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) 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") if err := client.Close(); err != nil { diff --git a/smtpclient/client.go b/smtpclient/client.go index 9ff8629c1d..a4c67eb81c 100644 --- a/smtpclient/client.go +++ b/smtpclient/client.go @@ -44,17 +44,27 @@ var ( "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 ( - ErrSize = errors.New("message too large for remote smtp server") // SMTP server announced a maximum message size and the message to be delivered exceeds it. - Err8bitmimeUnsupported = errors.New("remote smtp server does not implement 8bitmime extension, required by message") - ErrSMTPUTF8Unsupported = errors.New("remote smtp server does not implement smtputf8 extension, required by message") - ErrStatus = errors.New("remote smtp server sent unexpected response status code") // Relatively common, e.g. when a 250 OK was expected and server sent 451 temporary error. - ErrProtocol = errors.New("smtp protocol error") // After a malformed SMTP response or inconsistent multi-line response. - ErrTLS = errors.New("tls error") // E.g. handshake failure, or hostname verification was required and failed. - ErrBotched = errors.New("smtp connection is botched") // Set on a client, and returned for new operations, after an i/o error or malformed SMTP response. - ErrClosed = errors.New("client is closed") + ErrSize = errors.New("message too large for remote smtp server") // SMTP server announced a maximum message size and the message to be delivered exceeds it. + Err8bitmimeUnsupported = errors.New("remote smtp server does not implement 8bitmime extension, required by message") + ErrSMTPUTF8Unsupported = errors.New("remote smtp server does not implement smtputf8 extension, required by message") + ErrRequireTLSUnsupported = errors.New("remote smtp server does not implement requiretls extension, required for delivery") + ErrStatus = errors.New("remote smtp server sent unexpected response status code") // Relatively common, e.g. when a 250 OK was expected and server sent 451 temporary error. + ErrProtocol = errors.New("smtp protocol error") // After a malformed SMTP response or inconsistent multi-line response. + ErrTLS = errors.New("tls error") // E.g. handshake failure, or hostname verification was required and failed. + ErrBotched = errors.New("smtp connection is botched") // Set on a client, and returned for new operations, after an i/o error or malformed SMTP response. + ErrClosed = errors.New("client is closed") ) // TLSMode indicates if TLS must, should or must not be used. @@ -67,7 +77,8 @@ const ( // Required TLS with STARTTLS for SMTP servers, without verifiying the certificate. // 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" // 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. cmds []string // Last or active command, for generating errors and metrics. 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. 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. extSMTPUTF8 bool // Remote server supports SMTPUTF8 extension. extAuthMechanisms []string // Supported authentication mechanisms. + extRequireTLS bool // Remote supports REQUIRETLS extension. } // Error represents a failure to deliver a message. @@ -185,7 +198,9 @@ func (e Error) Error() string { // 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 // 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 // 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 { // todo: we could also verify DANE here. not applicable to SMTP delivery. config := c.tlsConfig(tlsMode) - tlsconn := tls.Client(conn, &config) + tlsconn := tls.Client(conn, config) if err := tlsconn.HandshakeContext(ctx); err != nil { return nil, err } c.conn = tlsconn tlsversion, ciphersuite := mox.TLSInfo(tlsconn) c.log.Debug("tls client handshake done", mlog.Field("tls", tlsversion), mlog.Field("ciphersuite", ciphersuite), mlog.Field("servername", remoteHostname)) + c.tls = true } else { c.conn = conn } @@ -239,12 +255,26 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, ehl return c, nil } -func (c *Client) tlsConfig(tlsMode TLSMode) tls.Config { +func (c *Client) tlsConfig(tlsMode TLSMode) *tls.Config { 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? - return tls.Config{ + return &tls.Config{ ServerName: c.remoteHostname.ASCII, RootCAs: mox.Conf.Static.TLS.CertPool, InsecureSkipVerify: tlsMode == TLSOpportunistic || tlsMode == TLSUnverifiedStartTLS, @@ -538,6 +568,8 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do c.ext8bitmime = true case "PIPELINING": c.extPipelining = true + case "REQUIRETLS": + c.extRequireTLS = true default: // For SMTPUTF8 we must ignore any parameter. ../rfc/6531:207 if s == "SMTPUTF8" || strings.HasPrefix(s, "SMTPUTF8 ") { @@ -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. // ../rfc/8461:646 tlsConfig := c.tlsConfig(tlsMode) - nconn := tls.Client(conn, &tlsConfig) + nconn := tls.Client(conn, tlsConfig) c.conn = nconn 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) 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) } @@ -720,6 +753,24 @@ func (c *Client) SupportsSMTPUTF8() bool { return c.extSMTPUTF8 } +// SupportsStartTLS returns whether the SMTP server supports the STARTTLS +// extension. +func (c *Client) SupportsStartTLS() bool { + return c.extStartTLS +} + +// SupportsRequireTLS returns whether the SMTP server supports the REQUIRETLS +// extension. The REQUIRETLS extension is only announced after enabling +// STARTTLS. +func (c *Client) SupportsRequireTLS() bool { + return c.extRequireTLS +} + +// 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. // // 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, // the remote server must support the SMTPUTF8 extension or delivery will fail. // +// If requireTLS is true, the remote server must support the REQUIRETLS +// extension, or delivery will fail. +// // Deliver uses the following SMTP extensions if the remote server supports them: // 8BITMIME, SMTPUTF8, SIZE, PIPELINING, ENHANCEDSTATUSCODES, STARTTLS. // // Returned errors can be of type Error, one of the Err-variables in this package // or other underlying errors, e.g. for i/o. Use errors.Is to check. -func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, msgSize int64, msg io.Reader, req8bitmime, reqSMTPUTF8 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) if c.origConn == nil { @@ -761,6 +815,9 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms // ../rfc/6531:313 c.xerrorf(false, 0, "", "", "%w", ErrSMTPUTF8Unsupported) } + if !c.extRequireTLS && requireTLS { + c.xerrorf(false, 0, "", "", "%w", ErrRequireTLSUnsupported) + } if c.extSize && msgSize > c.maxSize { c.xerrorf(true, 0, "", "", "%w: message is %d bytes, remote has a %d bytes maximum size", ErrSize, msgSize, c.maxSize) @@ -782,12 +839,17 @@ func (c *Client) Deliver(ctx context.Context, mailFrom string, rcptTo string, ms // ../rfc/6531:213 smtputf8Arg = " SMTPUTF8" } + var requiretlsArg string + if requireTLS { + // ../rfc/8689:155 + requiretlsArg = " REQUIRETLS" + } // Transaction overview: ../rfc/5321:1015 // MAIL FROM: ../rfc/5321:1879 // RCPT TO: ../rfc/5321:1916 // DATA: ../rfc/5321:1992 - lineMailFrom := fmt.Sprintf("MAIL FROM:<%s>%s%s%s", 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) // We are going into a transaction. We'll clear this when done. diff --git a/smtpclient/client_test.go b/smtpclient/client_test.go index 8fdd359196..192d66ebef 100644 --- a/smtpclient/client_test.go +++ b/smtpclient/client_test.go @@ -36,6 +36,8 @@ func TestClient(t *testing.T) { ctx := context.Background() log := mlog.New("smtpclient") + mlog.SetConfig(map[string]mlog.Level{"": mlog.LevelTrace}) + type options struct { pipelining bool ecodes bool @@ -43,13 +45,15 @@ func TestClient(t *testing.T) { starttls bool eightbitmime bool smtputf8 bool + requiretls bool ehlo bool - tlsMode TLSMode - tlsHostname dns.Domain - need8bitmime bool - needsmtputf8 bool - auths []string // Allowed mechanisms. + tlsMode TLSMode + tlsHostname dns.Domain + need8bitmime bool + needsmtputf8 bool + needsrequiretls bool + auths []string // Allowed mechanisms. nodeliver bool // For server, whether client will attempt a delivery. } @@ -146,6 +150,9 @@ func TestClient(t *testing.T) { if opts.smtputf8 { writeline("250-SMTPUTF8") } + if opts.requiretls && haveTLS { + writeline("250-REQUIRETLS") + } if opts.auths != nil { writeline("250-AUTH " + strings.Join(opts.auths, " ")) } @@ -260,6 +267,7 @@ func TestClient(t *testing.T) { result <- nil }() + // todo: should abort tests more properly. on client failures, we may be left with hanging test. go func() { defer func() { x := recover() @@ -268,7 +276,8 @@ func TestClient(t *testing.T) { } }() 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") } 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 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) { fail("first deliver: got err %v, expected %v", err, expDeliverErr) } @@ -288,7 +297,7 @@ func TestClient(t *testing.T) { if err != nil { 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) { fail("second deliver: got err %v, expected %v", err, expDeliverErr) } @@ -327,11 +336,13 @@ test smtputf8: true, starttls: true, ehlo: true, + requiretls: true, - tlsMode: TLSStrictStartTLS, - tlsHostname: dns.Domain{ASCII: "mox.example"}, - need8bitmime: true, - needsmtputf8: true, + tlsMode: TLSStrictStartTLS, + tlsHostname: dns.Domain{ASCII: "mox.example"}, + need8bitmime: true, + needsmtputf8: true, + needsrequiretls: true, } 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-256"}}, []sasl.Client{sasl.NewClientSCRAMSHA256("test", "test")}, nil, nil, nil) // 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. // ../rfc/7435:424 @@ -441,7 +453,7 @@ func TestErrors(t *testing.T) { panic(err) } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) @@ -461,7 +473,7 @@ func TestErrors(t *testing.T) { panic(err) } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err)) @@ -483,7 +495,7 @@ func TestErrors(t *testing.T) { panic(err) } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with not-Permanent", err)) @@ -507,7 +519,7 @@ func TestErrors(t *testing.T) { panic(err) } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) @@ -543,7 +555,7 @@ func TestErrors(t *testing.T) { panic(err) } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err)) @@ -574,14 +586,14 @@ func TestErrors(t *testing.T) { } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with non-Permanent", err)) } // 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 { panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) } @@ -604,7 +616,7 @@ func TestErrors(t *testing.T) { } 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 if err == nil || !errors.Is(err, ErrStatus) || !errors.As(err, &xerr) || !xerr.Permanent { panic(fmt.Errorf("got %#v, expected ErrStatus with Permanent", err)) diff --git a/smtpserver/dsn.go b/smtpserver/dsn.go index b553e73f48..d5acffda5c 100644 --- a/smtpserver/dsn.go +++ b/smtpserver/dsn.go @@ -13,7 +13,7 @@ import ( ) // 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) if err != nil { return err @@ -46,7 +46,11 @@ func queueDSN(ctx context.Context, c *conn, rcptTo smtp.Path, m dsn.Message) err // ../rfc/3464:433 const has8bit = 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 nil diff --git a/smtpserver/fuzz_test.go b/smtpserver/fuzz_test.go index 6ba218216b..9c2e1fbad5 100644 --- a/smtpserver/fuzz_test.go +++ b/smtpserver/fuzz_test.go @@ -100,7 +100,7 @@ func FuzzServer(f *testing.F) { const submission = false err := serverConn.SetDeadline(time.Now().Add(time.Second)) 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++ } diff --git a/smtpserver/server.go b/smtpserver/server.go index afad5be4c9..952eedd533 100644 --- a/smtpserver/server.go +++ b/smtpserver/server.go @@ -17,6 +17,7 @@ import ( "io" "math" "net" + "net/textproto" "os" "runtime/debug" "sort" @@ -201,7 +202,7 @@ func Listen() { port := config.Port(listener.SMTP.Port, 25) for _, ip := range listener.IPs { 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 { @@ -211,7 +212,7 @@ func Listen() { } port := config.Port(listener.Submission.Port, 587) 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) 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() -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)) if os.Getuid() == 0 { 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 } 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 tls bool + extRequireTLS bool // Whether to announce and allow the REQUIRETLS extension. resolver dns.Resolver r *bufio.Reader w *bufio.Writer @@ -316,8 +318,9 @@ type conn struct { // Message transaction. mailFrom *smtp.Path - 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. + requireTLS *bool // MAIL FROM with REQUIRETLS set. + 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 } @@ -353,6 +356,7 @@ func (c *conn) reset() { // ../rfc/5321:2502 func (c *conn) rset() { c.mailFrom = nil + c.requireTLS = nil c.has8bitmime = false c.smtputf8 = false 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. -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 if a, ok := nc.LocalAddr().(*net.TCPAddr); ok { localIP = a.IP @@ -545,6 +549,7 @@ func serve(listenerName string, cid int64, hostname dns.Domain, tlsConfig *tls.C conn: nc, submission: submission, tls: tls, + extRequireTLS: requireTLS, resolver: resolver, lastlog: time.Now(), tlsConfig: tlsConfig, @@ -821,6 +826,10 @@ func (c *conn) cmdHello(p *parser, ehlo bool) { if !c.tls && c.tlsConfig != nil { // ../rfc/3207:90 c.bwritelinef("250-STARTTLS") + } else if c.extRequireTLS { + // ../rfc/8689:202 + // ../rfc/8689:143 + c.bwritelinef("250-REQUIRETLS") } if c.submission { // ../rfc/4954:123 @@ -1285,6 +1294,15 @@ func (c *conn) cmdMail(p *parser) { case "SMTPUTF8": // ../rfc/6531:213 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: // ../rfc/5321:2230 xsmtpUserErrorf(smtp.C555UnrecognizedAddrParams, smtp.SeSys3NotSupported3, "unrecognized parameter %q", key) @@ -1645,7 +1663,12 @@ func (c *conn) cmdData(p *parser) { recvHdr := &message.HeaderWriter{} // For additional Received-header clauses, see: // 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 { tlsConn := c.conn.(*tls.Conn) 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. 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\( @@ -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") } + // 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 // server will add it. // ../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...) 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 // probably result in errors as well... 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") } + // 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. authResults := message.AuthResults{ 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 { 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() c.log.Errorx("queuing DSN for incoming delivery, no DSN sent", err) } diff --git a/smtpserver/server_test.go b/smtpserver/server_test.go index 035628a56d..69e2bafcfb 100644 --- a/smtpserver/server_test.go +++ b/smtpserver/server_test.go @@ -90,6 +90,7 @@ type testserver struct { auth []sasl.Client user, pass string submission bool + requiretls bool dnsbls []dns.Domain tlsmode smtpclient.TLSMode } @@ -145,7 +146,7 @@ func (ts *testserver) run(fn func(helloErr error, client *smtpclient.Client)) { tlsConfig := &tls.Config{ 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) }() @@ -222,7 +223,7 @@ func TestSubmission(t *testing.T) { mailFrom := "mjl@mox.example" rcptTo := "remote@example.org" 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 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" rcptTo := "mjl@127.0.0.10" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { @@ -273,7 +274,7 @@ func TestDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@test.example" // Not configured as destination. 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { @@ -285,7 +286,7 @@ func TestDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "unknown@mox.example" // User unknown. 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { @@ -297,7 +298,7 @@ func TestDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { @@ -311,7 +312,7 @@ func TestDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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") @@ -442,7 +443,7 @@ func TestSpam(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { @@ -458,7 +459,7 @@ func TestSpam(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl2@mox.example" 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") @@ -477,7 +478,7 @@ func TestSpam(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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") @@ -499,7 +500,7 @@ func TestSpam(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 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++ { - 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") } @@ -585,7 +586,7 @@ happens to come from forwarding mail server. tcompare(t, n, 10) // 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 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) @@ -603,7 +604,7 @@ happens to come from forwarding mail server. 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 { tcheck(t, err, "deliver") } else { @@ -620,7 +621,7 @@ happens to come from forwarding mail server. 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 { tcheck(t, err, "deliver") } else { @@ -669,7 +670,7 @@ func TestDMARCSent(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { @@ -688,7 +689,7 @@ func TestDMARCSent(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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") }) @@ -721,7 +722,7 @@ func TestBlocklistedSubjectpass(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C451LocalErr { @@ -740,7 +741,7 @@ func TestBlocklistedSubjectpass(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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 if err == nil || !errors.As(err, &cerr) || cerr.Code != smtp.C550MailboxUnavail { @@ -758,7 +759,7 @@ func TestBlocklistedSubjectpass(t *testing.T) { rcptTo := "mjl@mox.example" passMessage := strings.Replace(deliverMessage, "Subject: test", "Subject: test "+pass, 1) 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") }) @@ -800,7 +801,7 @@ func TestDMARCReport(t *testing.T) { msg := msgb.String() 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") @@ -922,7 +923,7 @@ func TestTLSReport(t *testing.T) { msg = headers + msg 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") @@ -1021,11 +1022,11 @@ func TestRatelimitDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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") - 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 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) @@ -1050,11 +1051,11 @@ func TestRatelimitDelivery(t *testing.T) { mailFrom := "remote@example.org" rcptTo := "mjl@mox.example" 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") - 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 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) @@ -1076,7 +1077,7 @@ func TestNonSMTP(t *testing.T) { tlsConfig := &tls.Config{ 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) }() @@ -1123,7 +1124,7 @@ func TestLimitOutgoing(t *testing.T) { t.Helper() mailFrom := "mjl@mox.example" 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 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() mailFrom := "mjl@other.example" 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 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" 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") @@ -1298,7 +1299,7 @@ func TestPostmaster(t *testing.T) { t.Helper() mailFrom := "mjl@other.example" 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 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() mailFrom := `""@other.example` 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 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) } + +// 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: +To: +Subject: test +Message-Id: +TLS-Required: No + +test email +`, "\n", "\r\n") + + msg1 := strings.ReplaceAll(`From: +To: +Subject: test +Message-Id: +TLS-Required: No +TLS-Required: bogus + +test email +`, "\n", "\r\n") + + msg2 := strings.ReplaceAll(`From: +To: +Subject: test +Message-Id: + +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) + } + }) +} diff --git a/store/account.go b/store/account.go index ef5104fbb5..ad50043358 100644 --- a/store/account.go +++ b/store/account.go @@ -669,8 +669,17 @@ type Outgoing struct { 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. -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. type Account struct { diff --git a/webadmin/admin.go b/webadmin/admin.go index 7d2baa80b8..7ea92abefc 100644 --- a/webadmin/admin.go +++ b/webadmin/admin.go @@ -1823,6 +1823,13 @@ func (Admin) QueueDrop(ctx context.Context, id int64) { 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. func (Admin) LogLevels(ctx context.Context) map[string]string { m := map[string]string{} diff --git a/webadmin/admin.html b/webadmin/admin.html index 2a9b5be904..66ddbc3d1d 100644 --- a/webadmin/admin.html +++ b/webadmin/admin.html @@ -1537,7 +1537,6 @@ ]) const nowSecs = new Date().getTime()/1000 - let transport const page = document.getElementById('page') dom._kids(page, @@ -1560,63 +1559,97 @@ dom.th('Next attempt'), dom.th('Last attempt'), dom.th('Last error'), + dom.th('Require TLS'), dom.th('Transport/Retry'), dom.th('Remove'), ), ), dom.tbody( - msgs.map(m => dom.tr( - dom.td(''+m.ID), - dom.td(age(new Date(m.Queued), false, nowSecs)), - dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart - dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart - dom.td(formatSize(m.Size)), - dom.td(''+m.Attempts), - dom.td(age(new Date(m.NextAttempt), true, nowSecs)), - dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), - dom.td(m.LastError || '-'), - dom.td( - transport=dom.select( - attr({title: 'Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'}), - dom.option('(default)', attr({value: ''})), - Object.keys(transports).sort().map(t => dom.option(t, m.Transport === t ? attr({checked: ''}) : [])), + msgs.map(m => { + let requiretls, requiretlsFieldset, transport + return dom.tr( + dom.td(''+m.ID), + dom.td(age(new Date(m.Queued), false, nowSecs)), + dom.td(m.SenderLocalpart+"@"+ipdomainString(m.SenderDomain)), // todo: escaping of localpart + dom.td(m.RecipientLocalpart+"@"+ipdomainString(m.RecipientDomain)), // todo: escaping of localpart + dom.td(formatSize(m.Size)), + dom.td(''+m.Attempts), + dom.td(age(new Date(m.NextAttempt), true, nowSecs)), + dom.td(m.LastAttempt ? age(new Date(m.LastAttempt), false, nowSecs) : '-'), + dom.td(m.LastError || '-'), + dom.td( + dom.form( + 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.button('Retry now', async function click(e) { - e.preventDefault() - try { - e.target.disabled = true - await api.QueueKick(m.ID, transport.value) - } catch (err) { - console.log({err}) - window.alert('Error: ' + err.message) - return - } finally { - e.target.disabled = false - } - window.location.reload() // todo: only refresh the list - }), - ), - dom.td( - dom.button('Remove', async function click(e) { - e.preventDefault() - if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { - return - } - try { - 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 - }), - ), - )), + dom.td( + dom.form( + transport=dom.select( + attr({title: 'Transport to use for delivery attempts. The default is direct delivery, connecting to the MX hosts of the domain.'}), + dom.option('(default)', attr({value: ''})), + Object.keys(transports).sort().map(t => dom.option(t, m.Transport === t ? attr({checked: ''}) : [])), + ), + ' ', + dom.button('Retry now'), + async function submit(e) { + e.preventDefault() + try { + e.target.disabled = true + await api.QueueKick(m.ID, transport.value) + } catch (err) { + console.log({err}) + window.alert('Error: ' + err.message) + return + } finally { + e.target.disabled = false + } + window.location.reload() // todo: only refresh the list + } + ), + ), + dom.td( + dom.button('Remove', async function click(e) { + e.preventDefault() + if (!window.confirm('Are you sure you want to remove this message? It will be removed completely.')) { + return + } + try { + 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 + }), + ), + ) + }) ), ), ], diff --git a/webadmin/adminapi.json b/webadmin/adminapi.json index 233649ba39..09549b9b0a 100644 --- a/webadmin/adminapi.json +++ b/webadmin/adminapi.json @@ -622,6 +622,26 @@ ], "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", "Docs": "LogLevels returns the current log levels.", @@ -3040,6 +3060,14 @@ "Typewords": [ "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" + ] } ] }, diff --git a/webmail/api.go b/webmail/api.go index 01334593eb..9e94ab73ab 100644 --- a/webmail/api.go +++ b/webmail/api.go @@ -16,6 +16,7 @@ import ( "net/mail" "net/textproto" "os" + "runtime/debug" "sort" "strings" "sync" @@ -33,6 +34,7 @@ import ( "github.com/mjl-/mox/dkim" "github.com/mjl-/mox/dns" "github.com/mjl-/mox/message" + "github.com/mjl-/mox/metrics" "github.com/mjl-/mox/mlog" "github.com/mjl-/mox/mox-" "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. ReplyTo string // If non-empty, Reply-To header to add to message. 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. @@ -522,6 +525,9 @@ func (w Webmail) MessageSubmit(ctx context.Context, m SubmitMessage) { if m.UserAgent != "" { header("User-Agent", m.UserAgent) } + if m.RequireTLS != nil && !*m.RequireTLS { + header("TLS-Required", "No") + } header("MIME-Version", "1.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, 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 { metricSubmission.WithLabelValues("queueerror").Inc() } @@ -1635,12 +1641,27 @@ const ( SecurityResultUnknown SecurityResult = "unknown" ) -// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain). -// Fields are nil when an error occurred during analysis. +// RecipientSecurity is a quick analysis of the security properties of delivery to +// the recipient (domain). type RecipientSecurity struct { - 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. - DANE SecurityResult // Whether first delivery destination has DANE records. + // 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. + 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 @@ -1650,6 +1671,18 @@ func (Webmail) RecipientSecurity(ctx context.Context, messageAddressee string) ( 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. func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddressee string) (RecipientSecurity, error) { log := xlog.WithContext(ctx) @@ -1658,6 +1691,8 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres SecurityResultUnknown, SecurityResultUnknown, SecurityResultUnknown, + SecurityResultUnknown, + SecurityResultUnknown, } msgAddr, err := mail.ParseAddress(messageAddressee) @@ -1675,6 +1710,7 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres // MTA-STS. wg.Add(1) go func() { + defer logPanic(ctx) defer wg.Done() policy, _, err := mtastsdb.Get(ctx, resolver, addr.Domain) @@ -1690,6 +1726,7 @@ func recipientSecurity(ctx context.Context, resolver dns.Resolver, messageAddres // DNSSEC and DANE. wg.Add(1) go func() { + defer logPanic(ctx) defer wg.Done() _, 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() + return rs, nil } diff --git a/webmail/api.json b/webmail/api.json index 7598ca5bad..afe8777ed2 100644 --- a/webmail/api.json +++ b/webmail/api.json @@ -1090,6 +1090,14 @@ "Typewords": [ "string" ] + }, + { + "Name": "RequireTLS", + "Docs": "For \"Require TLS\" extension during delivery.", + "Typewords": [ + "nullable", + "bool" + ] } ] }, @@ -1256,8 +1264,15 @@ }, { "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": [ + { + "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", "Docs": "Whether we have a stored enforced MTA-STS policy, or domain has MTA-STS DNS record.", @@ -1278,6 +1293,13 @@ "Typewords": [ "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" + ] } ] }, diff --git a/webmail/api.ts b/webmail/api.ts index 19e089eb21..58806bda7e 100644 --- a/webmail/api.ts +++ b/webmail/api.ts @@ -142,6 +142,7 @@ export interface SubmitMessage { 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. 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 @@ -177,12 +178,14 @@ export interface Mailbox { Size: number // Number of bytes for all messages. } -// RecipientSecurity is a quick analysis of the security properties of delivery to the recipient (domain). -// Fields are nil when an error occurred during analysis. +// RecipientSecurity is a quick analysis of the security properties of delivery to +// the recipient (domain). 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. DNSSEC: SecurityResult // Whether MX lookup response was DNSSEC-signed. 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 @@ -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"]}]}, "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"]}]}, - "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"]}]}, "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"]}]}, - "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"]}]}, "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"]}]}, diff --git a/webmail/api_test.go b/webmail/api_test.go index dde7a115f7..915ff8ea25 100644 --- a/webmail/api_test.go +++ b/webmail/api_test.go @@ -366,7 +366,12 @@ func TestAPI(t *testing.T) { // RecipientSecurity 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, 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}) } diff --git a/webmail/msg.js b/webmail/msg.js index 748b03c722..a730370c35 100644 --- a/webmail/msg.js +++ b/webmail/msg.js @@ -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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, diff --git a/webmail/text.js b/webmail/text.js index cdfaea8eb9..adf2a4fa58 100644 --- a/webmail/text.js +++ b/webmail/text.js @@ -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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, diff --git a/webmail/webmail.js b/webmail/webmail.js index 75176289dd..44e862c2bf 100644 --- a/webmail/webmail.js +++ b/webmail/webmail.js @@ -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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, - "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"] }] }, "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"] }] }, @@ -1039,7 +1039,7 @@ To simulate slow API calls and SSE events: 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: @@ -1987,6 +1987,7 @@ const compose = (opts) => { let subject; let body; let attachments; + let requiretls; let toBtn, ccBtn, bccBtn, replyToBtn, customFromBtn; let replyToCell, toCell, ccCell, bccCell; // Where we append new address views. 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 }, IsForward: opts.isForward || false, ResponseMessageID: opts.responseMessageID || 0, + RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', }; await client.MessageSubmit(message); cmdCancel(); @@ -2042,10 +2044,10 @@ const compose = (opts) => { const cmdSend = async () => { await withStatus('Sending email', submit(), fieldset); }; - const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow); }; - const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow); }; - const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow); }; - const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true); }; + const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow); }; + const cmdAddCc = async () => { newAddrView('', true, ccViews, ccBtn, ccCell, ccRow); }; + const cmdAddBcc = async () => { newAddrView('', true, bccViews, bccBtn, bccCell, bccRow); }; + const cmdReplyTo = async () => { newAddrView('', false, replytoViews, replyToBtn, replyToCell, replyToRow, true); }; const cmdCustomFrom = async () => { if (customFrom) { return; @@ -2063,7 +2065,7 @@ const compose = (opts) => { 'ctrl Y': cmdReplyTo, // 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) { return; } @@ -2096,11 +2098,13 @@ const compose = (opts) => { } return '#aaa'; }; - const setBar = (c0, c1, c2) => { + const setBar = (c0, c1, c2, c3, c4) => { const stops = [ - c0 + ' 0%', c0 + ' 32%', 'white 32%', 'white 33%', - c1 + ' 33%', c1 + ' 66%', 'white 66%', 'white 67%', - c2 + ' 67%', c2 + ' 100%', + c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%', + c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%', + c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%', + c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%', + c4 + ' 80%', c4 + ' 100%', ].join(', '); securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1'; }; @@ -2108,19 +2112,56 @@ const compose = (opts) => { rcptSecAborter = aborter; rcptSecPromise = client.withOptions({ aborter: aborter }).RecipientSecurity(inputElem.value); 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; + 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; + if (requiretls.value === 'yes') { + requiretls.value = ''; + } }); }; - const root = dom.span(autosizeElem = dom.span(dom._class('autosize'), inputElem = dom.input(focusPlaceholder('Jane '), 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 '), style({ width: 'auto' }), attr.value(addr), newAddressComplete(), attr.title(recipientSecurityTitle), function keydown(e) { if (e.key === '-' && e.ctrlKey) { remove(); } else if (e.key === '=' && e.ctrlKey) { - newAddrView('', views, btn, cell, row, single); + newAddrView('', isRecipient, views, btn, cell, row, single); } else { return; @@ -2143,7 +2184,6 @@ const compose = (opts) => { } }), ' '); autosizeElem.dataset.value = inputElem.value; - fetchRecipientSecurity(); const remove = () => { const i = views.indexOf(v); views.splice(i, 1); @@ -2170,7 +2210,8 @@ const compose = (opts) => { next.focus(); } }; - const v = { root: root, input: inputElem }; + const v = { root: root, input: inputElem, isRecipient: isRecipient, recipientSecurity: null }; + fetchRecipientSecurity(); views.push(v); cell.appendChild(v.root); row.style.display = ''; @@ -2255,16 +2296,16 @@ const compose = (opts) => { return v; }), dom.label(style({ color: '#666' }), dom.input(attr.type('checkbox'), function change(e) { 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(); shortcutCmd(cmdSend, shortcuts); })); subjectAutosize.dataset.value = subject.value; - (opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)); - (opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)); - (opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)); + (opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, true, toViews, toBtn, toCell, toRow)); + (opts.cc || []).forEach(s => newAddrView(s, true, ccViews, ccBtn, ccCell, ccRow)); + (opts.bcc || []).forEach(s => newAddrView(s, true, bccViews, bccBtn, bccCell, bccRow)); 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) { ccRow.style.display = 'none'; diff --git a/webmail/webmail.ts b/webmail/webmail.ts index c7d6569dae..c62105343a 100644 --- a/webmail/webmail.ts +++ b/webmail/webmail.ts @@ -52,7 +52,7 @@ To simulate slow API calls and SSE events: 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: @@ -1154,6 +1154,8 @@ const compose = (opts: ComposeOptions) => { type AddrView = { root: HTMLElement input: HTMLInputElement + isRecipient: boolean + recipientSecurity: null | api.RecipientSecurity } let fieldset: HTMLFieldSetElement @@ -1163,6 +1165,7 @@ const compose = (opts: ComposeOptions) => { let subject: HTMLInputElement let body: HTMLTextAreaElement let attachments: HTMLInputElement + let requiretls: HTMLSelectElement 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. @@ -1217,6 +1220,7 @@ const compose = (opts: ComposeOptions) => { ForwardAttachments: forwardAttachmentPaths.length === 0 ? {MessageID: 0, Paths: []} : {MessageID: opts.attachmentsMessageItem!.Message.ID, Paths: forwardAttachmentPaths}, IsForward: opts.isForward || false, ResponseMessageID: opts.responseMessageID || 0, + RequireTLS: requiretls.value === '' ? null : requiretls.value === 'yes', } await client.MessageSubmit(message) cmdCancel() @@ -1226,10 +1230,10 @@ const compose = (opts: ComposeOptions) => { await withStatus('Sending email', submit(), fieldset) } - const cmdAddTo = async () => { newAddrView('', toViews, toBtn, toCell, toRow) } - const cmdAddCc = async () => { newAddrView('', ccViews, ccBtn, ccCell, ccRow) } - const cmdAddBcc = async () => { newAddrView('', bccViews, bccBtn, bccCell, bccRow) } - const cmdReplyTo = async () => { newAddrView('', replytoViews, replyToBtn, replyToCell, replyToRow, true) } + const cmdAddTo = async () => { newAddrView('', true, toViews, toBtn, toCell, toRow) } + const cmdAddCc = async () => { newAddrView('', true, ccViews, ccBtn, ccCell, ccRow) } + const cmdAddBcc = async () => { newAddrView('', true, bccViews, bccBtn, bccCell, bccRow) } + const cmdReplyTo = async () => { newAddrView('', false, replytoViews, replyToBtn, replyToCell, replyToRow, true) } const cmdCustomFrom = async () => { if (customFrom) { 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. } - 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) { return } @@ -1285,11 +1289,13 @@ const compose = (opts: ComposeOptions) => { } return '#aaa' } - const setBar = (c0: string, c1: string, c2: string) => { + const setBar = (c0: string, c1: string, c2: string, c3: string, c4: string) => { const stops = [ - c0 + ' 0%', c0 + ' 32%', 'white 32%', 'white 33%', - c1 + ' 33%', c1 + ' 66%', 'white 66%', 'white 67%', - c2 + ' 67%', c2 + ' 100%', + c0 + ' 0%', c0 + ' 19%', 'white 19%', 'white 20%', + c1 + ' 20%', c1 + ' 39%', 'white 39%', 'white 40%', + c2 + ' 40%', c2 + ' 59%', 'white 59%', 'white 60%', + c3 + ' 60%', c3 + ' 79%', 'white 79%', 'white 80%', + c4 + ' 80%', c4 + ' 100%', ].join(', ') securityBar.style.borderImage = 'linear-gradient(to right, ' + stops + ') 1' } @@ -1298,14 +1304,53 @@ const compose = (opts: ComposeOptions) => { rcptSecAborter = aborter rcptSecPromise = client.withOptions({aborter: aborter}).RecipientSecurity(inputElem.value) 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 + 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 + 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( autosizeElem=dom.span( dom._class('autosize'), @@ -1314,12 +1359,12 @@ const compose = (opts: ComposeOptions) => { 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)'), + attr.title(recipientSecurityTitle), function keydown(e: KeyboardEvent) { if (e.key === '-' && e.ctrlKey) { remove() } else if (e.key === '=' && e.ctrlKey) { - newAddrView('', views, btn, cell, row, single) + newAddrView('', isRecipient, views, btn, cell, row, single) } else { return } @@ -1353,7 +1398,6 @@ const compose = (opts: ComposeOptions) => { ' ', ) autosizeElem.dataset.value = inputElem.value - fetchRecipientSecurity() const remove = () => { 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) cell.appendChild(v.root) row.style.display = '' @@ -1541,8 +1588,21 @@ const compose = (opts: ComposeOptions) => { }), ' (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'), + 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: SubmitEvent) { e.preventDefault() @@ -1553,11 +1613,11 @@ const compose = (opts: ComposeOptions) => { subjectAutosize.dataset.value = subject.value - ;(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, toViews, toBtn, toCell, toRow)) - ;(opts.cc || []).forEach(s => newAddrView(s, ccViews, ccBtn, ccCell, ccRow)) - ;(opts.bcc || []).forEach(s => newAddrView(s, bccViews, bccBtn, bccCell, bccRow)) + ;(opts.to && opts.to.length > 0 ? opts.to : ['']).forEach(s => newAddrView(s, true, toViews, toBtn, toCell, toRow)) + ;(opts.cc || []).forEach(s => newAddrView(s,true, ccViews, ccBtn, ccCell, ccRow)) + ;(opts.bcc || []).forEach(s => newAddrView(s, true, bccViews, bccBtn, bccCell, bccRow)) 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) { ccRow.style.display = 'none'