mirror of
https://github.com/mjl-/mox.git
synced 2024-12-26 16:33:47 +03:00
outgoing dmarc/tls reporting improvements
- dmarc reports: add a cid to the log line about one run of sending reports, and log line for each report - in smtpclient, also handle tls errors from the first read after a handshake. we appear to sometimes get tls alerts about bad certificates on the first read. - for messages to dmarc/tls reporting addresses that we think should/can not be processed as reports, add an X-Mox- header explaining the reason. - tls reports: send report messages with From address of postmaster at an actually configured domain for the mail host. and only send reports when dkim signing is configured for that domain. the domain is also the submitter domain. the rfc seems to require dkim-signing with an exact match with the message from and submitter. - for incoming tls reports, in the smtp server, we do allow a dkim-signature domain that is higher-level (up to publicsuffix) of the message from domain. so we are stricter in what we send than what we receive.
This commit is contained in:
parent
b2af63b3ec
commit
61bae75228
7 changed files with 136 additions and 91 deletions
|
@ -341,12 +341,13 @@ func Start(resolver dns.Resolver) {
|
||||||
_, err := bstore.QueryDB[Evaluation](ctx, db).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
|
_, err := bstore.QueryDB[Evaluation](ctx, db).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
|
||||||
log.Check(err, "removing stale dmarc evaluations from database")
|
log.Check(err, "removing stale dmarc evaluations from database")
|
||||||
|
|
||||||
log.Info("sending dmarc aggregate reports", mlog.Field("end", nextEnd.UTC()), mlog.Field("intervals", intervals))
|
clog := log.WithCid(mox.Cid())
|
||||||
if err := sendReports(ctx, log.WithCid(mox.Cid()), resolver, db, nextEnd, intervals); err != nil {
|
clog.Info("sending dmarc aggregate reports", mlog.Field("end", nextEnd.UTC()), mlog.Field("intervals", intervals))
|
||||||
log.Errorx("sending dmarc aggregate reports", err)
|
if err := sendReports(ctx, clog, resolver, db, nextEnd, intervals); err != nil {
|
||||||
|
clog.Errorx("sending dmarc aggregate reports", err)
|
||||||
metricReportError.Inc()
|
metricReportError.Inc()
|
||||||
} else {
|
} else {
|
||||||
log.Info("finding sending dmarc aggregate reports")
|
clog.Info("finished sending dmarc aggregate reports")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
@ -443,6 +444,7 @@ func sendReports(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
rlog := log.WithCid(mox.Cid()).Fields(mlog.Field("domain", domain))
|
rlog := log.WithCid(mox.Cid()).Fields(mlog.Field("domain", domain))
|
||||||
|
rlog.Info("sending dmarc report")
|
||||||
if _, err := sendReportDomain(ctx, rlog, resolver, db, endTime, domain); err != nil {
|
if _, err := sendReportDomain(ctx, rlog, resolver, db, endTime, domain); err != nil {
|
||||||
rlog.Errorx("sending dmarc aggregate report to domain", err)
|
rlog.Errorx("sending dmarc aggregate report to domain", err)
|
||||||
metricReportError.Inc()
|
metricReportError.Inc()
|
||||||
|
|
|
@ -11,6 +11,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
|
"reflect"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
@ -113,15 +114,16 @@ type Client struct {
|
||||||
recipientDomainResult *tlsrpt.Result // Either "sts" or "no-policy-found".
|
recipientDomainResult *tlsrpt.Result // Either "sts" or "no-policy-found".
|
||||||
hostResult *tlsrpt.Result // Either "dane" or "no-policy-found".
|
hostResult *tlsrpt.Result // Either "dane" or "no-policy-found".
|
||||||
|
|
||||||
r *bufio.Reader
|
r *bufio.Reader
|
||||||
w *bufio.Writer
|
w *bufio.Writer
|
||||||
tr *moxio.TraceReader // Kept for changing trace levels between cmd/auth/data.
|
tr *moxio.TraceReader // Kept for changing trace levels between cmd/auth/data.
|
||||||
tw *moxio.TraceWriter
|
tw *moxio.TraceWriter
|
||||||
log *mlog.Log
|
log *mlog.Log
|
||||||
lastlog time.Time // For adding delta timestamps between log lines.
|
lastlog time.Time // For adding delta timestamps between log lines.
|
||||||
cmds []string // Last or active command, for generating errors and metrics.
|
cmds []string // Last or active command, for generating errors and metrics.
|
||||||
cmdStart time.Time // Start of command.
|
cmdStart time.Time // Start of command.
|
||||||
tls bool // Whether connection is TLS protected.
|
tls bool // Whether connection is TLS protected.
|
||||||
|
firstReadAfterHandshake bool // To detect TLS alert error from remote just after handshake.
|
||||||
|
|
||||||
botched bool // If set, protocol is out of sync and no further commands can be sent.
|
botched bool // If set, protocol is out of sync and no further commands can be sent.
|
||||||
needRset bool // If set, a new delivery requires an RSET command.
|
needRset bool // If set, a new delivery requires an RSET command.
|
||||||
|
@ -274,6 +276,7 @@ func New(ctx context.Context, log *mlog.Log, conn net.Conn, tlsMode TLSMode, tls
|
||||||
c.tlsResultAdd(0, 1, err)
|
c.tlsResultAdd(0, 1, err)
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
c.firstReadAfterHandshake = true
|
||||||
c.tlsResultAdd(1, 0, nil)
|
c.tlsResultAdd(1, 0, nil)
|
||||||
c.conn = tlsconn
|
c.conn = tlsconn
|
||||||
tlsversion, ciphersuite := mox.TLSInfo(tlsconn)
|
tlsversion, ciphersuite := mox.TLSInfo(tlsconn)
|
||||||
|
@ -444,8 +447,19 @@ func (c *Client) readline() (string, error) {
|
||||||
|
|
||||||
line, err := bufs.Readline(c.r)
|
line, err := bufs.Readline(c.r)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// See if this is a TLS alert from remote, and one other than 0 (which notifies
|
||||||
|
// that the connection is being closed. If so, we register a TLS connection
|
||||||
|
// failure. This handles TLS alerts that happen just after a successful handshake.
|
||||||
|
var netErr *net.OpError
|
||||||
|
if c.firstReadAfterHandshake && errors.As(err, &netErr) && netErr.Op == "remote error" && netErr.Err != nil && reflect.ValueOf(netErr.Err).Kind() == reflect.Uint8 && reflect.ValueOf(netErr.Err).Uint() != 0 {
|
||||||
|
resultType, reasonCode := tlsrpt.TLSFailureDetails(err)
|
||||||
|
// We count -1 success to compensate for the assumed success right after the handshake.
|
||||||
|
c.tlsResultAddFailureDetails(-1, 1, c.tlsrptFailureDetails(resultType, reasonCode))
|
||||||
|
}
|
||||||
|
|
||||||
return line, c.botchf(0, "", "", "%s: %w", strings.Join(c.cmds, ","), err)
|
return line, c.botchf(0, "", "", "%s: %w", strings.Join(c.cmds, ","), err)
|
||||||
}
|
}
|
||||||
|
c.firstReadAfterHandshake = false
|
||||||
return line, nil
|
return line, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -749,6 +763,7 @@ func (c *Client) hello(ctx context.Context, tlsMode TLSMode, ehloHostname dns.Do
|
||||||
c.tlsResultAdd(0, 1, err)
|
c.tlsResultAdd(0, 1, err)
|
||||||
c.xerrorf(false, 0, "", "", "%w: STARTTLS TLS handshake: %s", ErrTLS, err)
|
c.xerrorf(false, 0, "", "", "%w: STARTTLS TLS handshake: %s", ErrTLS, err)
|
||||||
}
|
}
|
||||||
|
c.firstReadAfterHandshake = true
|
||||||
cancel()
|
cancel()
|
||||||
c.tr = moxio.NewTraceReader(c.log, "RS: ", c.conn)
|
c.tr = moxio.NewTraceReader(c.log, "RS: ", c.conn)
|
||||||
c.tw = moxio.NewTraceWriter(c.log, "LC: ", c.conn) // No need to wrap in timeoutWriter, it would just set the timeout on the underlying connection, which is still active.
|
c.tw = moxio.NewTraceWriter(c.log, "LC: ", c.conn) // No need to wrap in timeoutWriter, it would just set the timeout on the underlying connection, which is still active.
|
||||||
|
|
|
@ -5,6 +5,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/mjl-/bstore"
|
"github.com/mjl-/bstore"
|
||||||
|
@ -17,6 +18,7 @@ import (
|
||||||
"github.com/mjl-/mox/iprev"
|
"github.com/mjl-/mox/iprev"
|
||||||
"github.com/mjl-/mox/mlog"
|
"github.com/mjl-/mox/mlog"
|
||||||
"github.com/mjl-/mox/mox-"
|
"github.com/mjl-/mox/mox-"
|
||||||
|
"github.com/mjl-/mox/publicsuffix"
|
||||||
"github.com/mjl-/mox/smtp"
|
"github.com/mjl-/mox/smtp"
|
||||||
"github.com/mjl-/mox/store"
|
"github.com/mjl-/mox/store"
|
||||||
"github.com/mjl-/mox/subjectpass"
|
"github.com/mjl-/mox/subjectpass"
|
||||||
|
@ -48,6 +50,9 @@ type analysis struct {
|
||||||
tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
|
tlsReport *tlsrpt.Report // Validated TLS report, not yet stored.
|
||||||
reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
|
reason string // If non-empty, reason for this decision. Can be one of reputationMethod and a few other tokens.
|
||||||
dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
|
dmarcOverrideReason string // If set, one of dmarcrpt.PolicyOverride
|
||||||
|
// Additional headers to add during delivery. Used for reasons a message to a
|
||||||
|
// dmarc/tls reporting address isn't processed.
|
||||||
|
headers string
|
||||||
}
|
}
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
@ -81,6 +86,8 @@ func isListDomain(d delivery, ld dns.Domain) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delivery) analysis {
|
func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delivery) analysis {
|
||||||
|
var headers string
|
||||||
|
|
||||||
mailbox := d.rcptAcc.destination.Mailbox
|
mailbox := d.rcptAcc.destination.Mailbox
|
||||||
if mailbox == "" {
|
if mailbox == "" {
|
||||||
mailbox = "Inbox"
|
mailbox = "Inbox"
|
||||||
|
@ -96,7 +103,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
// todo: on temporary failures, reject temporarily?
|
// todo: on temporary failures, reject temporarily?
|
||||||
if isListDomain(d, rs.ListAllowDNSDomain) {
|
if isListDomain(d, rs.ListAllowDNSDomain) {
|
||||||
d.m.IsMailingList = true
|
d.m.IsMailingList = true
|
||||||
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList)}
|
return analysis{accept: true, mailbox: mailbox, reason: reasonListAllow, dmarcOverrideReason: string(dmarcrpt.PolicyOverrideMailingList), headers: headers}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -165,7 +172,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
if mberr != nil {
|
if mberr != nil {
|
||||||
return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason}
|
return analysis{false, mailbox, smtp.C451LocalErr, smtp.SeSys3Other0, false, "error processing", err, nil, nil, reasonReputationError, dmarcOverrideReason, headers}
|
||||||
}
|
}
|
||||||
d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
|
d.m.MailboxID = 0 // We plan to reject, no need to set intended MailboxID.
|
||||||
}
|
}
|
||||||
|
@ -179,7 +186,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
d.m.Seen = true
|
d.m.Seen = true
|
||||||
log.Info("accepting reject to configured mailbox due to ruleset")
|
log.Info("accepting reject to configured mailbox due to ruleset")
|
||||||
}
|
}
|
||||||
return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason}
|
return analysis{accept, mailbox, code, secode, err == nil, errmsg, err, nil, nil, reason, dmarcOverrideReason, headers}
|
||||||
}
|
}
|
||||||
|
|
||||||
if d.dmarcUse && d.dmarcResult.Reject {
|
if d.dmarcUse && d.dmarcResult.Reject {
|
||||||
|
@ -194,14 +201,19 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
// Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
|
// Messages with DMARC aggregate reports must have a DMARC pass. ../rfc/7489:1866
|
||||||
if d.dmarcResult.Status != dmarc.StatusPass {
|
if d.dmarcResult.Status != dmarc.StatusPass {
|
||||||
log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
|
log.Info("received dmarc aggregate report without dmarc pass, not processing as dmarc report")
|
||||||
|
headers += "X-Mox-DMARCReport-Error: no DMARC pass\r\n"
|
||||||
} else if report, err := dmarcrpt.ParseMessageReport(log, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
} else if report, err := dmarcrpt.ParseMessageReport(log, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
||||||
log.Infox("parsing dmarc aggregate report", err)
|
log.Infox("parsing dmarc aggregate report", err)
|
||||||
|
headers += "X-Mox-DMARCReport-Error: could not parse report\r\n"
|
||||||
} else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
|
} else if d, err := dns.ParseDomain(report.PolicyPublished.Domain); err != nil {
|
||||||
log.Infox("parsing domain in dmarc aggregate report", err)
|
log.Infox("parsing domain in dmarc aggregate report", err)
|
||||||
|
headers += "X-Mox-DMARCReport-Error: could not parse domain in published policy\r\n"
|
||||||
} else if _, ok := mox.Conf.Domain(d); !ok {
|
} else if _, ok := mox.Conf.Domain(d); !ok {
|
||||||
log.Info("dmarc aggregate report for domain not configured, ignoring", mlog.Field("domain", d))
|
log.Info("dmarc aggregate report for domain not configured, ignoring", mlog.Field("domain", d))
|
||||||
|
headers += "X-Mox-DMARCReport-Error: published policy domain unrecognized\r\n"
|
||||||
} else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
|
} else if report.ReportMetadata.DateRange.End > time.Now().Unix()+60 {
|
||||||
log.Info("dmarc aggregate report with end date in the future, ignoring", mlog.Field("domain", d), mlog.Field("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
|
log.Info("dmarc aggregate report with end date in the future, ignoring", mlog.Field("domain", d), mlog.Field("end", time.Unix(report.ReportMetadata.DateRange.End, 0)))
|
||||||
|
headers += "X-Mox-DMARCReport-Error: report has end date in the future\r\n"
|
||||||
} else {
|
} else {
|
||||||
dmarcReport = report
|
dmarcReport = report
|
||||||
}
|
}
|
||||||
|
@ -211,6 +223,11 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
// reputation, defaulting to accept.
|
// reputation, defaulting to accept.
|
||||||
var tlsReport *tlsrpt.Report
|
var tlsReport *tlsrpt.Report
|
||||||
if d.rcptAcc.destination.HostTLSReports || d.rcptAcc.destination.DomainTLSReports {
|
if d.rcptAcc.destination.HostTLSReports || d.rcptAcc.destination.DomainTLSReports {
|
||||||
|
matchesDomain := func(sigDomain dns.Domain) bool {
|
||||||
|
// RFC seems to require exact DKIM domain match with submitt and message From, we
|
||||||
|
// also allow msgFrom to be subdomain. ../rfc/8460:322
|
||||||
|
return sigDomain == d.msgFrom.Domain || strings.HasSuffix(d.msgFrom.Domain.ASCII, "."+sigDomain.ASCII) && publicsuffix.Lookup(ctx, d.msgFrom.Domain) == publicsuffix.Lookup(ctx, sigDomain)
|
||||||
|
}
|
||||||
// Valid DKIM signature for domain must be present. We take "valid" to assume
|
// Valid DKIM signature for domain must be present. We take "valid" to assume
|
||||||
// "passing", not "syntactically valid". We also check for "tlsrpt" as service.
|
// "passing", not "syntactically valid". We also check for "tlsrpt" as service.
|
||||||
// This check is optional, but if anyone goes through the trouble to explicitly
|
// This check is optional, but if anyone goes through the trouble to explicitly
|
||||||
|
@ -220,12 +237,12 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
for _, r := range d.dkimResults {
|
for _, r := range d.dkimResults {
|
||||||
// The record should have an allowed service "tlsrpt". The RFC mentions it as if
|
// The record should have an allowed service "tlsrpt". The RFC mentions it as if
|
||||||
// the service must be specified explicitly, but the default allowed services for a
|
// the service must be specified explicitly, but the default allowed services for a
|
||||||
// DKIM record are "*", which includes "tlsrpt". Unless a the DKIM record
|
// DKIM record are "*", which includes "tlsrpt". Unless a DKIM record explicitly
|
||||||
// explicitly specifies services (e.g. s=email), a record will work for TLS
|
// specifies services (e.g. s=email), a record will work for TLS reports. The DKIM
|
||||||
// reports. The DKIM records seen used for TLS reporting in the wild don't
|
// records seen used for TLS reporting in the wild don't explicitly set "s" for
|
||||||
// explicitly set "s" for services.
|
// services.
|
||||||
// ../rfc/8460:326
|
// ../rfc/8460:326
|
||||||
if r.Status == dkim.StatusPass && r.Sig.Domain == d.msgFrom.Domain && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
|
if r.Status == dkim.StatusPass && matchesDomain(r.Sig.Domain) && r.Sig.Length < 0 && r.Record.ServiceAllowed("tlsrpt") {
|
||||||
ok = true
|
ok = true
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
@ -233,8 +250,10 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
|
|
||||||
if !ok {
|
if !ok {
|
||||||
log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
|
log.Info("received mail to tlsrpt without acceptable DKIM signature, not processing as tls report")
|
||||||
|
headers += "X-Mox-TLSReport-Error: no acceptable DKIM signature\r\n"
|
||||||
} else if report, err := tlsrpt.ParseMessage(log, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
} else if report, err := tlsrpt.ParseMessage(log, store.FileMsgReader(d.m.MsgPrefix, d.dataFile)); err != nil {
|
||||||
log.Infox("parsing tls report", err)
|
log.Infox("parsing tls report", err)
|
||||||
|
headers += "X-Mox-TLSReport-Error: could not parse TLS report\r\n"
|
||||||
} else {
|
} else {
|
||||||
var known bool
|
var known bool
|
||||||
for _, p := range report.Policies {
|
for _, p := range report.Policies {
|
||||||
|
@ -248,6 +267,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
}
|
}
|
||||||
if !known {
|
if !known {
|
||||||
log.Info("tls report without one of configured domains, ignoring")
|
log.Info("tls report without one of configured domains, ignoring")
|
||||||
|
headers += "X-Mox-TLSReport-Error: report for unknown domain\r\n"
|
||||||
} else {
|
} else {
|
||||||
tlsReport = report
|
tlsReport = report
|
||||||
}
|
}
|
||||||
|
@ -279,12 +299,12 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
log.Info("reputation analyzed", mlog.Field("conclusive", conclusive), mlog.Field("isjunk", isjunk), mlog.Field("method", string(method)))
|
log.Info("reputation analyzed", mlog.Field("conclusive", conclusive), mlog.Field("isjunk", isjunk), mlog.Field("method", string(method)))
|
||||||
if conclusive {
|
if conclusive {
|
||||||
if !*isjunk {
|
if !*isjunk {
|
||||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason}
|
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reason, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
|
||||||
}
|
}
|
||||||
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
|
return reject(smtp.C451LocalErr, smtp.SeSys3Other0, "error processing", err, string(method))
|
||||||
} else if dmarcReport != nil || tlsReport != nil {
|
} else if dmarcReport != nil || tlsReport != nil {
|
||||||
log.Info("accepting message with dmarc aggregate report or tls report without reputation")
|
log.Info("accepting message with dmarc aggregate report or tls report without reputation")
|
||||||
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason}
|
return analysis{accept: true, mailbox: mailbox, dmarcReport: dmarcReport, tlsReport: tlsReport, reason: reasonReporting, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
|
||||||
}
|
}
|
||||||
// If there was no previous message from sender or its domain, and we have an SPF
|
// If there was no previous message from sender or its domain, and we have an SPF
|
||||||
// (soft)fail, reject the message.
|
// (soft)fail, reject the message.
|
||||||
|
@ -320,7 +340,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
pass := err == nil
|
pass := err == nil
|
||||||
log.Infox("pass by subject token", err, mlog.Field("pass", pass))
|
log.Infox("pass by subject token", err, mlog.Field("pass", pass))
|
||||||
if pass {
|
if pass {
|
||||||
return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason}
|
return analysis{accept: true, mailbox: mailbox, reason: reasonSubjectpass, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -400,7 +420,7 @@ func analyze(ctx context.Context, log *mlog.Log, resolver dns.Resolver, d delive
|
||||||
}
|
}
|
||||||
|
|
||||||
if accept {
|
if accept {
|
||||||
return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason}
|
return analysis{accept: true, mailbox: mailbox, reason: reasonNoBadSignals, dmarcOverrideReason: dmarcOverrideReason, headers: headers}
|
||||||
}
|
}
|
||||||
|
|
||||||
if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
|
if subjectpassKey != "" && d.dmarcResult.Status == dmarc.StatusPass && method == methodNone && (dnsblocklisted || junkSubjectpass) {
|
||||||
|
|
|
@ -2448,15 +2448,16 @@ func (c *conn) deliver(ctx context.Context, recvHdrFor func(string) string, msgW
|
||||||
rcptAuthResults.Methods = append(rcptAuthResults.Methods, rcptDMARCMethod)
|
rcptAuthResults.Methods = append(rcptAuthResults.Methods, rcptDMARCMethod)
|
||||||
|
|
||||||
// Prepend reason as message header, for easy display in mail clients.
|
// Prepend reason as message header, for easy display in mail clients.
|
||||||
var xmoxreason string
|
var xmox string
|
||||||
if a.reason != "" {
|
if a.reason != "" {
|
||||||
xmoxreason = "X-Mox-Reason: " + a.reason + "\r\n"
|
xmox = "X-Mox-Reason: " + a.reason + "\r\n"
|
||||||
}
|
}
|
||||||
|
xmox += a.headers
|
||||||
|
|
||||||
// ../rfc/5321:3204
|
// ../rfc/5321:3204
|
||||||
// Received-SPF header goes before Received. ../rfc/7208:2038
|
// Received-SPF header goes before Received. ../rfc/7208:2038
|
||||||
m.MsgPrefix = []byte(
|
m.MsgPrefix = []byte(
|
||||||
xmoxreason +
|
xmox +
|
||||||
"Delivered-To: " + rcptAcc.rcptTo.XString(c.smtputf8) + "\r\n" + // ../rfc/9228:274
|
"Delivered-To: " + rcptAcc.rcptTo.XString(c.smtputf8) + "\r\n" + // ../rfc/9228:274
|
||||||
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
|
"Return-Path: <" + c.mailFrom.String() + ">\r\n" + // ../rfc/5321:3300
|
||||||
rcptAuthResults.Header() +
|
rcptAuthResults.Header() +
|
||||||
|
|
|
@ -64,6 +64,15 @@ func (r *Result) Add(success, failure int64, fds ...FailureDetails) {
|
||||||
r.Summary.TotalSuccessfulSessionCount += success
|
r.Summary.TotalSuccessfulSessionCount += success
|
||||||
r.Summary.TotalFailureSessionCount += failure
|
r.Summary.TotalFailureSessionCount += failure
|
||||||
|
|
||||||
|
// In smtpclient we can compensate with a negative success, after failed read after
|
||||||
|
// successful handshake. Sanity check that we never get negative counts.
|
||||||
|
if r.Summary.TotalSuccessfulSessionCount < 0 {
|
||||||
|
r.Summary.TotalSuccessfulSessionCount = 0
|
||||||
|
}
|
||||||
|
if r.Summary.TotalFailureSessionCount < 0 {
|
||||||
|
r.Summary.TotalFailureSessionCount = 0
|
||||||
|
}
|
||||||
|
|
||||||
Merge:
|
Merge:
|
||||||
for _, nfd := range fds {
|
for _, nfd := range fds {
|
||||||
for i, fd := range r.FailureDetails {
|
for i, fd := range r.FailureDetails {
|
||||||
|
|
|
@ -114,12 +114,13 @@ func Start(resolver dns.Resolver) {
|
||||||
_, err := bstore.QueryDB[tlsrptdb.TLSResult](ctx, db).FilterLess("DayUTC", endUTC.Add((-48-12)*time.Hour).Format("20060102")).Delete()
|
_, err := bstore.QueryDB[tlsrptdb.TLSResult](ctx, db).FilterLess("DayUTC", endUTC.Add((-48-12)*time.Hour).Format("20060102")).Delete()
|
||||||
log.Check(err, "removing stale tls results from database")
|
log.Check(err, "removing stale tls results from database")
|
||||||
|
|
||||||
log.Info("sending tls reports", mlog.Field("day", dayUTC))
|
clog := log.WithCid(mox.Cid())
|
||||||
if err := sendReports(ctx, log.WithCid(mox.Cid()), resolver, db, dayUTC, endUTC); err != nil {
|
clog.Info("sending tls reports", mlog.Field("day", dayUTC))
|
||||||
log.Errorx("sending tls reports", err)
|
if err := sendReports(ctx, clog, resolver, db, dayUTC, endUTC); err != nil {
|
||||||
|
clog.Errorx("sending tls reports", err)
|
||||||
metricReportError.Inc()
|
metricReportError.Inc()
|
||||||
} else {
|
} else {
|
||||||
log.Info("finished sending tls reports")
|
clog.Info("finished sending tls reports")
|
||||||
}
|
}
|
||||||
|
|
||||||
endUTC = endUTC.Add(24 * time.Hour)
|
endUTC = endUTC.Add(24 * time.Hour)
|
||||||
|
@ -224,6 +225,7 @@ func sendReports(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
rlog := log.WithCid(mox.Cid()).Fields(mlog.Field("policydomain", policyDomain), mlog.Field("daytutc", dayUTC))
|
rlog := log.WithCid(mox.Cid()).Fields(mlog.Field("policydomain", policyDomain), mlog.Field("daytutc", dayUTC))
|
||||||
|
rlog.Info("sending tls report")
|
||||||
if _, err := sendReportDomain(ctx, rlog, resolver, db, endTimeUTC, policyDomain, dayUTC); err != nil {
|
if _, err := sendReportDomain(ctx, rlog, resolver, db, endTimeUTC, policyDomain, dayUTC); err != nil {
|
||||||
rlog.Errorx("sending tls report to domain", err)
|
rlog.Errorx("sending tls report to domain", err)
|
||||||
metricReportError.Inc()
|
metricReportError.Inc()
|
||||||
|
@ -247,11 +249,37 @@ func removeResults(ctx context.Context, log *mlog.Log, db *bstore.DB, policyDoma
|
||||||
var queueAdd = queue.Add
|
var queueAdd = queue.Add
|
||||||
|
|
||||||
func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *bstore.DB, endUTC time.Time, policyDomain, dayUTC string) (cleanup bool, rerr error) {
|
func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *bstore.DB, endUTC time.Time, policyDomain, dayUTC string) (cleanup bool, rerr error) {
|
||||||
dom, err := dns.ParseDomain(policyDomain)
|
polDom, err := dns.ParseDomain(policyDomain)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("parsing policy domain for sending tls reports: %v", err)
|
return false, fmt.Errorf("parsing policy domain for sending tls reports: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reports need to be DKIM-signed by the submitter domain. Lookup the DKIM
|
||||||
|
// configuration now. If we don't have any, there is no point sending reports.
|
||||||
|
// todo spec: ../rfc/8460:322 "reporting domain" is a bit ambiguous. submitter domain is used in other places. it may be helpful in practice to allow dmarc-relaxed-like matching of the signing domain, so an address postmaster at mail host can send the reports using dkim keys at a higher-up domain (e.g. the publicsuffix domain).
|
||||||
|
fromDom := mox.Conf.Static.HostnameDomain
|
||||||
|
var confDKIM config.DKIM
|
||||||
|
for {
|
||||||
|
confDom, ok := mox.Conf.Domain(fromDom)
|
||||||
|
if len(confDom.DKIM.Sign) > 0 {
|
||||||
|
confDKIM = confDom.DKIM
|
||||||
|
break
|
||||||
|
} else if ok {
|
||||||
|
return true, fmt.Errorf("domain for mail host does not have dkim signing configured, report message cannot be dkim-signed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove least significant label.
|
||||||
|
var nfd dns.Domain
|
||||||
|
_, nfd.ASCII, _ = strings.Cut(fromDom.ASCII, ".")
|
||||||
|
_, nfd.Unicode, _ = strings.Cut(fromDom.Unicode, ".")
|
||||||
|
fromDom = nfd
|
||||||
|
|
||||||
|
var zerodom dns.Domain
|
||||||
|
if fromDom == zerodom {
|
||||||
|
return true, fmt.Errorf("no configured domain for mail host found, report message cannot be dkim-signed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// We'll cleanup records by default.
|
// We'll cleanup records by default.
|
||||||
cleanup = true
|
cleanup = true
|
||||||
// But if we encounter a temporary error we cancel cleanup of evaluations on error.
|
// But if we encounter a temporary error we cancel cleanup of evaluations on error.
|
||||||
|
@ -266,7 +294,7 @@ func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver,
|
||||||
}()
|
}()
|
||||||
|
|
||||||
// Get TLSRPT record. If there are no reporting addresses, we're not going to send at all.
|
// Get TLSRPT record. If there are no reporting addresses, we're not going to send at all.
|
||||||
record, _, err := tlsrpt.Lookup(ctx, resolver, dom)
|
record, _, err := tlsrpt.Lookup(ctx, resolver, polDom)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If there is no TLSRPT record, that's fine, we'll remove what we tracked.
|
// If there is no TLSRPT record, that's fine, we'll remove what we tracked.
|
||||||
if errors.Is(err, tlsrpt.ErrNoRecord) {
|
if errors.Is(err, tlsrpt.ErrNoRecord) {
|
||||||
|
@ -335,14 +363,14 @@ func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver,
|
||||||
beginUTC := endUTC.Add(-24 * time.Hour)
|
beginUTC := endUTC.Add(-24 * time.Hour)
|
||||||
|
|
||||||
report := tlsrpt.Report{
|
report := tlsrpt.Report{
|
||||||
OrganizationName: mox.Conf.Static.HostnameDomain.ASCII,
|
OrganizationName: fromDom.ASCII,
|
||||||
DateRange: tlsrpt.TLSRPTDateRange{
|
DateRange: tlsrpt.TLSRPTDateRange{
|
||||||
Start: beginUTC,
|
Start: beginUTC,
|
||||||
End: endUTC.Add(-time.Second), // Per example, ../rfc/8460:1769
|
End: endUTC.Add(-time.Second), // Per example, ../rfc/8460:1769
|
||||||
},
|
},
|
||||||
ContactInfo: "postmaster@" + mox.Conf.Static.HostnameDomain.ASCII,
|
ContactInfo: "postmaster@" + fromDom.ASCII,
|
||||||
// todo spec: ../rfc/8460:968 ../rfc/8460:1772 ../rfc/8460:691 subject header assumes a report-id in the form of a msg-id, but example and report-id json field explanation allows free-form report-id's (assuming we're talking about the same report-id here).
|
// todo spec: ../rfc/8460:968 ../rfc/8460:1772 ../rfc/8460:691 subject header assumes a report-id in the form of a msg-id, but example and report-id json field explanation allows free-form report-id's (assuming we're talking about the same report-id here).
|
||||||
ReportID: endUTC.Format("20060102") + "." + dom.ASCII + "@" + mox.Conf.Static.HostnameDomain.ASCII,
|
ReportID: endUTC.Format("20060102") + "." + polDom.ASCII + "@" + fromDom.ASCII,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Merge all results into this report.
|
// Merge all results into this report.
|
||||||
|
@ -380,10 +408,10 @@ func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver,
|
||||||
// typical setup the host is a subdomain of a configured domain with
|
// typical setup the host is a subdomain of a configured domain with
|
||||||
// DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
|
// DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
|
||||||
// todo future: when sending, use an SMTP MAIL FROM that we can relate back to recipient reporting address so we can stop trying to send reports in case of repeated delivery failure DSNs.
|
// todo future: when sending, use an SMTP MAIL FROM that we can relate back to recipient reporting address so we can stop trying to send reports in case of repeated delivery failure DSNs.
|
||||||
from := smtp.Address{Localpart: "postmaster", Domain: mox.Conf.Static.HostnameDomain}
|
from := smtp.Address{Localpart: "postmaster", Domain: fromDom}
|
||||||
|
|
||||||
// Subject follows the form from RFC. ../rfc/8460:959
|
// Subject follows the form from RFC. ../rfc/8460:959
|
||||||
subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", dom.ASCII, mox.Conf.Static.HostnameDomain.ASCII, report.ReportID)
|
subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", polDom.ASCII, fromDom, report.ReportID)
|
||||||
|
|
||||||
// Human-readable part for convenience. ../rfc/8460:917
|
// Human-readable part for convenience. ../rfc/8460:917
|
||||||
text := fmt.Sprintf(`
|
text := fmt.Sprintf(`
|
||||||
|
@ -397,13 +425,13 @@ Policy Domain: %s
|
||||||
Submitter: %s
|
Submitter: %s
|
||||||
Report-ID: %s
|
Report-ID: %s
|
||||||
Period: %s - %s UTC
|
Period: %s - %s UTC
|
||||||
`, dom, mox.Conf.Static.HostnameDomain, report.ReportID, beginUTC.Format(time.DateTime), endUTC.Format(time.DateTime))
|
`, polDom, fromDom, report.ReportID, beginUTC.Format(time.DateTime), endUTC.Format(time.DateTime))
|
||||||
|
|
||||||
// The attached file follows the naming convention from the RFC. ../rfc/8460:849
|
// The attached file follows the naming convention from the RFC. ../rfc/8460:849
|
||||||
reportFilename := fmt.Sprintf("%s!%s!%d!%d.json.gz", mox.Conf.Static.HostnameDomain.ASCII, dom.ASCII, beginUTC.Unix(), endUTC.Add(-time.Second).Unix())
|
reportFilename := fmt.Sprintf("%s!%s!%d!%d.json.gz", fromDom.ASCII, polDom.ASCII, beginUTC.Unix(), endUTC.Add(-time.Second).Unix())
|
||||||
|
|
||||||
// Compose the message.
|
// Compose the message.
|
||||||
msgPrefix, has8bit, smtputf8, messageID, err := composeMessage(ctx, log, msgf, dom, from, recipients, subject, text, reportFilename, reportFile)
|
msgPrefix, has8bit, smtputf8, messageID, err := composeMessage(ctx, log, msgf, polDom, confDKIM, from, recipients, subject, text, reportFilename, reportFile)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, fmt.Errorf("composing message with outgoing tls report: %v", err)
|
return false, fmt.Errorf("composing message with outgoing tls report: %v", err)
|
||||||
}
|
}
|
||||||
|
@ -442,7 +470,7 @@ Period: %s - %s UTC
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func composeMessage(ctx context.Context, log *mlog.Log, mf *os.File, policyDomain dns.Domain, fromAddr smtp.Address, recipients []message.NameAddress, subject, text, filename string, reportFile *os.File) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
|
func composeMessage(ctx context.Context, log *mlog.Log, mf *os.File, policyDomain dns.Domain, confDKIM config.DKIM, fromAddr smtp.Address, recipients []message.NameAddress, subject, text, filename string, reportFile *os.File) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
|
||||||
xc := message.NewComposer(mf, 100*1024*1024)
|
xc := message.NewComposer(mf, 100*1024*1024)
|
||||||
defer func() {
|
defer func() {
|
||||||
x := recover()
|
x := recover()
|
||||||
|
@ -469,7 +497,7 @@ func composeMessage(ctx context.Context, log *mlog.Log, mf *os.File, policyDomai
|
||||||
xc.Subject(subject)
|
xc.Subject(subject)
|
||||||
// ../rfc/8460:926
|
// ../rfc/8460:926
|
||||||
xc.Header("TLS-Report-Domain", policyDomain.ASCII)
|
xc.Header("TLS-Report-Domain", policyDomain.ASCII)
|
||||||
xc.Header("TLS-Report-Submitter", mox.Conf.Static.HostnameDomain.ASCII)
|
xc.Header("TLS-Report-Submitter", fromAddr.Domain.ASCII)
|
||||||
// TLS failures should be ignored. ../rfc/8460:317 ../rfc/8460:1050
|
// TLS failures should be ignored. ../rfc/8460:317 ../rfc/8460:1050
|
||||||
xc.Header("TLS-Required", "No")
|
xc.Header("TLS-Required", "No")
|
||||||
messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
|
messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
|
||||||
|
@ -514,46 +542,16 @@ func composeMessage(ctx context.Context, log *mlog.Log, mf *os.File, policyDomai
|
||||||
|
|
||||||
xc.Flush()
|
xc.Flush()
|
||||||
|
|
||||||
// Also sign the TLS-Report headers. ../rfc/8460:940
|
selectors := map[string]config.Selector{}
|
||||||
extraHeaders := []string{"TLS-Report-Domain", "TLS-Report-Submitter"}
|
for name, sel := range confDKIM.Selectors {
|
||||||
msgPrefix = dkimSign(ctx, log, fromAddr, smtputf8, mf, extraHeaders)
|
// Also sign the TLS-Report headers. ../rfc/8460:940
|
||||||
|
sel.HeadersEffective = append(append([]string{}, sel.HeadersEffective...), "TLS-Report-Domain", "TLS-Report-Submitter")
|
||||||
return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
|
selectors[name] = sel
|
||||||
}
|
|
||||||
|
|
||||||
func dkimSign(ctx context.Context, log *mlog.Log, fromAddr smtp.Address, smtputf8 bool, mf *os.File, extraHeaders []string) string {
|
|
||||||
// Add DKIM-Signature headers if we have a key for (a higher) domain than the from
|
|
||||||
// address, which is a host name. A signature will only be useful with higher-level
|
|
||||||
// domains if they have a relaxed dkim check (which is the default). If the dkim
|
|
||||||
// check is strict, there is no harm, there will simply not be a dkim pass.
|
|
||||||
fd := fromAddr.Domain
|
|
||||||
var zerodom dns.Domain
|
|
||||||
for fd != zerodom {
|
|
||||||
confDom, ok := mox.Conf.Domain(fd)
|
|
||||||
if ok && len(confDom.DKIM.Sign) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if len(confDom.DKIM.Sign) > 0 {
|
|
||||||
selectors := map[string]config.Selector{}
|
|
||||||
for name, sel := range confDom.DKIM.Selectors {
|
|
||||||
sel.HeadersEffective = append(append([]string{}, sel.HeadersEffective...), extraHeaders...)
|
|
||||||
selectors[name] = sel
|
|
||||||
}
|
|
||||||
confDom.DKIM.Selectors = selectors
|
|
||||||
|
|
||||||
dkimHeaders, err := dkim.Sign(ctx, fromAddr.Localpart, fd, confDom.DKIM, smtputf8, mf)
|
|
||||||
if err != nil {
|
|
||||||
log.Errorx("dkim-signing dmarc report, continuing without signature", err)
|
|
||||||
metricReportError.Inc()
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return dkimHeaders
|
|
||||||
}
|
|
||||||
|
|
||||||
var nfd dns.Domain
|
|
||||||
_, nfd.ASCII, _ = strings.Cut(fd.ASCII, ".")
|
|
||||||
_, nfd.Unicode, _ = strings.Cut(fd.Unicode, ".")
|
|
||||||
fd = nfd
|
|
||||||
}
|
}
|
||||||
return ""
|
confDKIM.Selectors = selectors
|
||||||
|
|
||||||
|
dkimHeader, err := dkim.Sign(ctx, fromAddr.Localpart, fromAddr.Domain, confDKIM, smtputf8, mf)
|
||||||
|
xc.Checkf(err, "dkim-signing report message")
|
||||||
|
|
||||||
|
return dkimHeader, xc.Has8bit, xc.SMTPUTF8, messageID, nil
|
||||||
}
|
}
|
||||||
|
|
|
@ -232,13 +232,13 @@ func TestSendReports(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
report1 := tlsrpt.Report{
|
report1 := tlsrpt.Report{
|
||||||
OrganizationName: "mail.mox.example",
|
OrganizationName: "mox.example",
|
||||||
DateRange: tlsrpt.TLSRPTDateRange{
|
DateRange: tlsrpt.TLSRPTDateRange{
|
||||||
Start: endUTC.Add(-24 * time.Hour),
|
Start: endUTC.Add(-24 * time.Hour),
|
||||||
End: endUTC.Add(-time.Second),
|
End: endUTC.Add(-time.Second),
|
||||||
},
|
},
|
||||||
ContactInfo: "postmaster@mail.mox.example",
|
ContactInfo: "postmaster@mox.example",
|
||||||
ReportID: endUTC.Format("20060102") + ".sender.example@mail.mox.example",
|
ReportID: endUTC.Format("20060102") + ".sender.example@mox.example",
|
||||||
Policies: []tlsrpt.Result{
|
Policies: []tlsrpt.Result{
|
||||||
{
|
{
|
||||||
Policy: tlsrpt.ResultPolicy{
|
Policy: tlsrpt.ResultPolicy{
|
||||||
|
@ -265,13 +265,13 @@ func TestSendReports(t *testing.T) {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
report2 := tlsrpt.Report{
|
report2 := tlsrpt.Report{
|
||||||
OrganizationName: "mail.mox.example",
|
OrganizationName: "mox.example",
|
||||||
DateRange: tlsrpt.TLSRPTDateRange{
|
DateRange: tlsrpt.TLSRPTDateRange{
|
||||||
Start: endUTC.Add(-24 * time.Hour),
|
Start: endUTC.Add(-24 * time.Hour),
|
||||||
End: endUTC.Add(-time.Second),
|
End: endUTC.Add(-time.Second),
|
||||||
},
|
},
|
||||||
ContactInfo: "postmaster@mail.mox.example",
|
ContactInfo: "postmaster@mox.example",
|
||||||
ReportID: endUTC.Format("20060102") + ".mailhost.sender.example@mail.mox.example",
|
ReportID: endUTC.Format("20060102") + ".mailhost.sender.example@mox.example",
|
||||||
Policies: []tlsrpt.Result{
|
Policies: []tlsrpt.Result{
|
||||||
{
|
{
|
||||||
Policy: tlsrpt.ResultPolicy{
|
Policy: tlsrpt.ResultPolicy{
|
||||||
|
|
Loading…
Reference in a new issue