3// Sending TLS reports and DMARC reports is very similar. See ../dmarcdb/eval.go:/similar and ../tlsrptsend/send.go:/similar.
25 "golang.org/x/exp/maps"
27 "github.com/prometheus/client_golang/prometheus"
28 "github.com/prometheus/client_golang/prometheus/promauto"
30 "github.com/mjl-/bstore"
32 "github.com/mjl-/mox/dkim"
33 "github.com/mjl-/mox/dmarc"
34 "github.com/mjl-/mox/dmarcrpt"
35 "github.com/mjl-/mox/dns"
36 "github.com/mjl-/mox/message"
37 "github.com/mjl-/mox/metrics"
38 "github.com/mjl-/mox/mlog"
39 "github.com/mjl-/mox/mox-"
40 "github.com/mjl-/mox/moxio"
41 "github.com/mjl-/mox/moxvar"
42 "github.com/mjl-/mox/publicsuffix"
43 "github.com/mjl-/mox/queue"
44 "github.com/mjl-/mox/smtp"
45 "github.com/mjl-/mox/store"
49 metricReport = promauto.NewCounter(
50 prometheus.CounterOpts{
51 Name: "mox_dmarcdb_report_queued_total",
52 Help: "Total messages with DMARC aggregate/error reports queued.",
55 metricReportError = promauto.NewCounter(
56 prometheus.CounterOpts{
57 Name: "mox_dmarcdb_report_error_total",
58 Help: "Total errors while composing or queueing DMARC aggregate/error reports.",
64 EvalDBTypes = []any{Evaluation{}, SuppressAddress{}} // Types stored in DB.
65 // Exported for backups. For incoming deliveries the SMTP server adds evaluations
66 // to the database. Every hour, a goroutine wakes up that gathers evaluations from
67 // the last hour(s), sends a report, and removes the evaluations from the database.
71// Evaluation is the result of an evaluation of a DMARC policy, to be included
73type Evaluation struct {
76 // Domain where DMARC policy was found, could be the organizational domain while
77 // evaluation was for a subdomain. Unicode. Same as domain found in
78 // PolicyPublished. A separate field for its index.
79 PolicyDomain string `bstore:"index"`
81 // Time of evaluation, determines which report (covering whole hours) this
82 // evaluation will be included in.
83 Evaluated time.Time `bstore:"default now"`
85 // If optional, this evaluation is not a reason to send a DMARC report, but it will
86 // be included when a report is sent due to other non-optional evaluations. Set for
87 // evaluations of incoming DMARC reports. We don't want such deliveries causing us to
88 // send a report, or we would keep exchanging reporting messages forever. Also set
89 // for when evaluation is a DMARC reject for domains we haven't positively
90 // interacted with, to prevent being used to flood an unsuspecting domain with
94 // Effective aggregate reporting interval in hours. Between 1 and 24, rounded up
95 // from seconds from policy to first number that can divide 24.
98 // "rua" in DMARC record, we only store evaluations for records with aggregate reporting addresses, so always non-empty.
101 // Policy used for evaluation. We don't store the "fo" field for failure reporting
102 // options, since we don't send failure reports for individual messages.
103 PolicyPublished dmarcrpt.PolicyPublished
105 // For "row" in a report record.
107 Disposition dmarcrpt.Disposition
110 OverrideReasons []dmarcrpt.PolicyOverrideReason
112 // For "identifiers" in a report record.
117 // For "auth_results" in a report record.
118 DKIMResults []dmarcrpt.DKIMAuthResult
119 SPFResults []dmarcrpt.SPFAuthResult
122// SuppressAddress is a reporting address for which outgoing DMARC reports
123// will be suppressed for a period.
124type SuppressAddress struct {
126 Inserted time.Time `bstore:"default now"`
127 ReportingAddress string `bstore:"unique"`
128 Until time.Time `bstore:"nonzero"`
132var dmarcResults = map[bool]dmarcrpt.DMARCResult{
133 false: dmarcrpt.DMARCFail,
134 true: dmarcrpt.DMARCPass,
137// ReportRecord turns an evaluation into a record that can be included in a
139func (e Evaluation) ReportRecord(count int) dmarcrpt.ReportRecord {
140 return dmarcrpt.ReportRecord{
142 SourceIP: e.SourceIP,
144 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
145 Disposition: e.Disposition,
146 DKIM: dmarcResults[e.AlignedDKIMPass],
147 SPF: dmarcResults[e.AlignedSPFPass],
148 Reasons: e.OverrideReasons,
151 Identifiers: dmarcrpt.Identifiers{
152 EnvelopeTo: e.EnvelopeTo,
153 EnvelopeFrom: e.EnvelopeFrom,
154 HeaderFrom: e.HeaderFrom,
156 AuthResults: dmarcrpt.AuthResults{
163var intervalOpts = []int{24, 12, 8, 6, 4, 3, 2}
165func intervalHours(seconds int) int {
166 hours := (seconds + 3600 - 1) / 3600
167 for _, opt := range intervalOpts {
175// AddEvaluation adds the result of a DMARC evaluation for an incoming message
178// AddEvaluation sets Evaluation.IntervalHours based on
179// aggregateReportingIntervalSeconds.
180func AddEvaluation(ctx context.Context, aggregateReportingIntervalSeconds int, e *Evaluation) error {
181 e.IntervalHours = intervalHours(aggregateReportingIntervalSeconds)
184 return EvalDB.Insert(ctx, e)
187// Evaluations returns all evaluations in the database.
188func Evaluations(ctx context.Context) ([]Evaluation, error) {
189 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
190 q.SortAsc("Evaluated")
194// EvaluationStat summarizes stored evaluations, for inclusion in an upcoming
195// aggregate report, for a domain.
196type EvaluationStat struct {
198 Dispositions []string
203// EvaluationStats returns evaluation counts and report-sending status per domain.
204func EvaluationStats(ctx context.Context) (map[string]EvaluationStat, error) {
205 r := map[string]EvaluationStat{}
207 err := bstore.QueryDB[Evaluation](ctx, EvalDB).ForEach(func(e Evaluation) error {
208 if stat, ok := r[e.PolicyDomain]; ok {
209 if !slices.Contains(stat.Dispositions, string(e.Disposition)) {
210 stat.Dispositions = append(stat.Dispositions, string(e.Disposition))
213 stat.SendReport = stat.SendReport || !e.Optional
214 r[e.PolicyDomain] = stat
216 dom, err := dns.ParseDomain(e.PolicyDomain)
218 return fmt.Errorf("parsing domain %q: %v", e.PolicyDomain, err)
220 r[e.PolicyDomain] = EvaluationStat{
222 Dispositions: []string{string(e.Disposition)},
224 SendReport: !e.Optional,
232// EvaluationsDomain returns all evaluations for a domain.
233func EvaluationsDomain(ctx context.Context, domain dns.Domain) ([]Evaluation, error) {
234 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
235 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
236 q.SortAsc("Evaluated")
240// RemoveEvaluationsDomain removes evaluations for domain so they won't be sent in
241// an aggregate report.
242func RemoveEvaluationsDomain(ctx context.Context, domain dns.Domain) error {
243 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
244 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
249var jitterRand = mox.NewPseudoRand()
251// time to sleep until next whole hour t, replaced by tests.
252// Jitter so we don't cause load at exactly whole hours, other processes may
253// already be doing that.
254var jitteredTimeUntil = func(t time.Time) time.Duration {
255 return time.Until(t.Add(time.Duration(30+jitterRand.Intn(60)) * time.Second))
258// Start launches a goroutine that wakes up at each whole hour (plus jitter) and
259// sends DMARC reports to domains that requested them.
260func Start(resolver dns.Resolver) {
262 log := mlog.New("dmarcdb", nil)
265 // In case of panic don't take the whole program down.
268 log.Error("recover from panic", slog.Any("panic", x))
270 metrics.PanicInc(metrics.Dmarcdb)
274 timer := time.NewTimer(time.Hour)
281 nextEnd := nextWholeHour(now)
282 timer.Reset(jitteredTimeUntil(nextEnd))
286 log.Info("dmarc aggregate report sender shutting down")
291 // Gather report intervals we want to process now. Multiples of hours that can
292 // divide 24, starting from UTC.
294 utchour := nextEnd.UTC().Hour()
299 for _, ival := range intervalOpts {
300 if ival*(utchour/ival) == utchour {
301 intervals = append(intervals, ival)
304 intervals = append(intervals, 1)
306 // Remove evaluations older than 48 hours (2 reports with the default and maximum
307 // 24 hour interval). They should have been processed by now. We may have kept them
308 // during temporary errors, but persistent temporary errors shouldn't fill up our
309 // database. This also cleans up evaluations that were all optional for a domain.
310 _, err := bstore.QueryDB[Evaluation](ctx, EvalDB).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
311 log.Check(err, "removing stale dmarc evaluations from database")
313 clog := log.WithCid(mox.Cid())
314 clog.Info("sending dmarc aggregate reports", slog.Time("end", nextEnd.UTC()), slog.Any("intervals", intervals))
315 if err := sendReports(ctx, clog, resolver, EvalDB, nextEnd, intervals); err != nil {
316 clog.Errorx("sending dmarc aggregate reports", err)
317 metricReportError.Inc()
319 clog.Info("finished sending dmarc aggregate reports")
325func nextWholeHour(now time.Time) time.Time {
328 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
331// We don't send reports at full speed. In the future, we could try to stretch out
332// reports a bit smarter. E.g. over 5 minutes with some minimum interval, and
333// perhaps faster and in parallel when there are lots of reports. Perhaps also
334// depending on reporting interval (faster for 1h, slower for 24h).
336var sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) {
337 t := time.NewTimer(between)
347// sendReports gathers all policy domains that have evaluations that should
348// receive a DMARC report and sends a report to each.
349func sendReports(ctx context.Context, log mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, intervals []int) error {
350 ivals := make([]any, len(intervals))
351 for i, v := range intervals {
355 destDomains := map[string]bool{}
357 // Gather all domains that we plan to send to.
359 q := bstore.QueryDB[Evaluation](ctx, db)
360 q.FilterLess("Evaluated", endTime)
361 q.FilterEqual("IntervalHours", ivals...)
362 err := q.ForEach(func(e Evaluation) error {
363 if !e.Optional && !destDomains[e.PolicyPublished.Domain] {
366 destDomains[e.PolicyPublished.Domain] = destDomains[e.PolicyPublished.Domain] || !e.Optional
370 return fmt.Errorf("looking for domains to send reports to: %v", err)
373 var wg sync.WaitGroup
375 // Sleep in between sending reports. We process hourly, and spread the reports over
376 // the hour, but with max 5 minute interval.
377 between := 45 * time.Minute
379 between /= time.Duration(nsend)
381 if between > 5*time.Minute {
382 between = 5 * time.Minute
385 // Attempt to send report to each domain.
387 for d, send := range destDomains {
388 // Cleanup evaluations for domain with only optionals.
390 removeEvaluations(ctx, log, db, endTime, d)
395 if ok := sleepBetween(ctx, between); !ok {
401 // Send in goroutine, so a slow process doesn't block progress.
403 go func(domain string) {
405 // In case of panic don't take the whole program down.
408 log.Error("unhandled panic in dmarcdb sendReports", slog.Any("panic", x))
410 metrics.PanicInc(metrics.Dmarcdb)
415 rlog := log.WithCid(mox.Cid()).With(slog.Any("domain", domain))
416 rlog.Info("sending dmarc report")
417 if _, err := sendReportDomain(ctx, rlog, resolver, db, endTime, domain); err != nil {
418 rlog.Errorx("sending dmarc aggregate report to domain", err)
419 metricReportError.Inc()
429type recipient struct {
434func parseRecipient(log mlog.Log, uri dmarc.URI) (r recipient, ok bool) {
435 log = log.With(slog.Any("uri", uri.Address))
437 u, err := url.Parse(uri.Address)
439 log.Debugx("parsing uri in dmarc record rua value", err)
442 if !strings.EqualFold(u.Scheme, "mailto") {
443 log.Debug("skipping unrecognized scheme in dmarc record rua value")
446 addr, err := smtp.ParseAddress(u.Opaque)
448 log.Debugx("parsing mailto uri in dmarc record rua value", err)
452 r = recipient{addr, uri.MaxSize}
458 r.maxSize *= 1024 * 1024
460 r.maxSize *= 1024 * 1024 * 1024
462 // Oh yeah, terabyte-sized reports!
463 r.maxSize *= 1024 * 1024 * 1024 * 1024
466 log.Debug("unrecognized max size unit in dmarc record rua value", slog.String("unit", uri.Unit))
473func removeEvaluations(ctx context.Context, log mlog.Log, db *bstore.DB, endTime time.Time, domain string) {
474 q := bstore.QueryDB[Evaluation](ctx, db)
475 q.FilterLess("Evaluated", endTime)
476 q.FilterNonzero(Evaluation{PolicyDomain: domain})
478 log.Check(err, "removing evaluations after processing for dmarc aggregate report")
481// replaceable for testing.
482var queueAdd = queue.Add
484func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, domain string) (cleanup bool, rerr error) {
485 dom, err := dns.ParseDomain(domain)
487 return false, fmt.Errorf("parsing domain for sending reports: %v", err)
490 // We'll cleanup records by default.
492 // If we encounter a temporary error we cancel cleanup of evaluations on error.
496 if !cleanup || tempError {
497 log.Debug("not cleaning up evaluations after attempting to send dmarc aggregate report")
499 removeEvaluations(ctx, log, db, endTime, domain)
503 // We're going to build up this report.
504 report := dmarcrpt.Feedback{
506 ReportMetadata: dmarcrpt.ReportMetadata{
507 OrgName: mox.Conf.Static.HostnameDomain.ASCII,
508 Email: "postmaster@" + mox.Conf.Static.HostnameDomain.ASCII,
509 // ReportID and DateRange are set after we've seen evaluations.
510 // Errors is filled below when we encounter problems.
512 // We'll fill the records below.
513 Records: []dmarcrpt.ReportRecord{},
516 var errors []string // For report.ReportMetaData.Errors
518 // Check if we should be sending a report at all: if there are rua URIs in the
519 // current DMARC record. The interval may have changed too, but we'll flush out our
520 // evaluations regardless. We always use the latest DMARC record when sending, but
521 // we'll lump all policies of the last interval into one report.
523 status, _, record, _, _, err := dmarc.Lookup(ctx, log.Logger, resolver, dom)
525 // todo future: we could perhaps still send this report, assuming the values we know. in case of temporary error, we could also schedule again regardless of next interval hour (we would now only retry a 24h-interval report after 24h passed).
526 // Remove records unless it was a temporary error. We'll try again next round.
527 cleanup = status != dmarc.StatusTemperror
528 return cleanup, fmt.Errorf("looking up current dmarc record for reporting address: %v", err)
531 var recipients []recipient
533 // Gather all aggregate reporting addresses to try to send to. We'll start with
534 // those in the initial DMARC record, but will follow external reporting addresses
535 // and possibly update the list.
536 for _, uri := range record.AggregateReportAddresses {
537 r, ok := parseRecipient(log, uri)
542 // Check if domain of rua recipient has the same organizational domain as for the
543 // evaluations. If not, we need to verify we are allowed to send.
544 ruaOrgDom := publicsuffix.Lookup(ctx, log.Logger, r.address.Domain)
545 evalOrgDom := publicsuffix.Lookup(ctx, log.Logger, dom)
547 if ruaOrgDom == evalOrgDom {
548 recipients = append(recipients, r)
552 // Verify and follow addresses in other organizational domain through
553 // <policydomain>._report._dmarc.<host> lookup.
555 accepts, status, records, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, log.Logger, resolver, evalOrgDom, r.address.Domain)
556 log.Debugx("checking if rua address with different organization domain has opted into receiving dmarc reports", err,
557 slog.Any("policydomain", evalOrgDom),
558 slog.Any("destinationdomain", r.address.Domain),
559 slog.Bool("accepts", accepts),
560 slog.Any("status", status))
561 if status == dmarc.StatusTemperror {
562 // With a temporary error, we'll try to get the report the delivered anyway,
563 // perhaps there are multiple recipients.
566 errors = append(errors, "temporary error checking authorization for report delegation to external address")
569 errors = append(errors, fmt.Sprintf("rua %s is external domain that does not opt-in to receiving dmarc records through _report dmarc record", r.address))
573 // We can follow a _report DMARC DNS record once. In that record, a domain may
574 // specify alternative addresses that we should send reports to instead. Such
575 // alternative address(es) must have the same host. If not, we ignore the new
576 // value. Behaviour for multiple records and/or multiple new addresses is
577 // underspecified. We'll replace an address with one or more new addresses, and
578 // keep the original if there was no candidate (which covers the case of invalid
579 // alternative addresses and no new address specified).
581 foundReplacement := false
582 rlog := log.With(slog.Any("followedaddress", uri.Address))
583 for _, record := range records {
584 for _, exturi := range record.AggregateReportAddresses {
585 extr, ok := parseRecipient(rlog, exturi)
589 if extr.address.Domain != r.address.Domain {
590 rlog.Debug("rua address in external _report dmarc record has different host than initial dmarc record, ignoring new name", slog.Any("externaladdress", extr.address))
591 errors = append(errors, fmt.Sprintf("rua %s is external domain with a replacement address %s with different host", r.address, extr.address))
593 rlog.Debug("using replacement rua address from external _report dmarc record", slog.Any("externaladdress", extr.address))
594 foundReplacement = true
595 recipients = append(recipients, extr)
599 if !foundReplacement {
600 recipients = append(recipients, r)
604 if len(recipients) == 0 {
605 // No reports requested, perfectly fine, no work to do for us.
606 log.Debug("no aggregate reporting addresses configured")
610 // We count idential records. Can be common with a domain sending quite some email.
611 // Though less if the sending domain has many IPs. In the future, we may want to
612 // remove some details from records so we can aggregate them into fewer rows.
613 type recordCount struct {
614 dmarcrpt.ReportRecord
617 counts := map[string]recordCount{}
619 var first, last Evaluation // For daterange.
622 q := bstore.QueryDB[Evaluation](ctx, db)
623 q.FilterLess("Evaluated", endTime)
624 q.FilterNonzero(Evaluation{PolicyDomain: domain})
625 q.SortAsc("Evaluated")
626 err = q.ForEach(func(e Evaluation) error {
632 record := e.ReportRecord(0)
634 // todo future: if we see many unique records from a single ip (exact ipv4 or ipv6 subnet), we may want to coalesce them into a single record, leaving out the fields that make them: a single ip could cause a report to contain many records with many unique domains, selectors, etc. it may compress relatively well, but the reports could still be huge.
636 // Simple but inefficient way to aggregate identical records. We may want to turn
637 // records into smaller representation in the future.
638 recbuf, err := xml.Marshal(record)
640 return fmt.Errorf("xml marshal of report record: %v", err)
642 recstr := string(recbuf)
643 counts[recstr] = recordCount{record, counts[recstr].count + 1}
650 return false, fmt.Errorf("gathering evaluations for report: %v", err)
654 log.Debug("no non-optional evaluations for domain, not sending dmarc aggregate report")
658 // Set begin and end date range. We try to set it to whole intervals as requested
659 // by the domain owner. The typical, default and maximum interval is 24 hours. But
660 // we allow any whole number of hours that can divide 24 hours. If we have an
661 // evaluation that is older, we may have had a failure to send earlier. We include
662 // those earlier intervals in this report as well.
664 // Although "end" could be interpreted as exclusive, to be on the safe side
665 // regarding client behaviour, and (related) to mimic large existing DMARC report
666 // senders, we set it to the last second of the period this report covers.
667 report.ReportMetadata.DateRange.End = endTime.Add(-time.Second).Unix()
668 interval := time.Duration(first.IntervalHours) * time.Hour
669 beginTime := endTime.Add(-interval)
670 for first.Evaluated.Before(beginTime) {
671 beginTime = beginTime.Add(-interval)
673 report.ReportMetadata.DateRange.Begin = beginTime.Unix()
675 // yyyymmddHH, we only send one report per hour, so should be unique per policy
676 // domain. We also add a truly unique id based on first evaluation id used without
677 // revealing the number of evaluations we have. Reuse of ReceivedID is not great,
678 // but shouldn't hurt.
679 report.ReportMetadata.ReportID = endTime.UTC().Format("20060102.15") + "." + mox.ReceivedID(first.ID)
681 // We may include errors we encountered when composing the report. We
682 // don't currently include errors about dmarc evaluations, e.g. DNS
683 // lookup errors during incoming deliveries.
684 report.ReportMetadata.Errors = errors
686 // We'll fill this with the last-used record, not the one we fetch fresh from DSN.
687 // They will almost always be the same, but if not, the fresh record was never
688 // actually used for evaluations, so no point in reporting it.
689 report.PolicyPublished = last.PolicyPublished
691 // Process records in-order for testable results.
692 recstrs := maps.Keys(counts)
693 sort.Strings(recstrs)
694 for _, recstr := range recstrs {
696 rc.ReportRecord.Row.Count = rc.count
697 report.Records = append(report.Records, rc.ReportRecord)
700 reportFile, err := store.CreateMessageTemp(log, "dmarcreportout")
702 return false, fmt.Errorf("creating temporary file for outgoing dmarc aggregate report: %v", err)
704 defer store.CloseRemoveTempFile(log, reportFile, "generated dmarc aggregate report")
706 gzw := gzip.NewWriter(reportFile)
707 _, err = fmt.Fprint(gzw, xml.Header)
708 enc := xml.NewEncoder(gzw)
709 enc.Indent("", "\t") // Keep up pretention that xml is human-readable.
711 err = enc.Encode(report)
720 return true, fmt.Errorf("writing dmarc aggregate report as xml with gzip: %v", err)
723 msgf, err := store.CreateMessageTemp(log, "dmarcreportmsgout")
725 return false, fmt.Errorf("creating temporary message file with outgoing dmarc aggregate report: %v", err)
727 defer store.CloseRemoveTempFile(log, msgf, "message with generated dmarc aggregate report")
729 // We are sending reports from our host's postmaster address. In a
730 // typical setup the host is a subdomain of a configured domain with
731 // DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
732 // A single report can contain deliveries from a single policy domain
733 // to multiple of our configured domains.
734 from := smtp.NewAddress("postmaster", mox.Conf.Static.HostnameDomain)
737 subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", dom.ASCII, mox.Conf.Static.HostnameDomain.ASCII, report.ReportMetadata.ReportID)
740 text := fmt.Sprintf(`Attached is an aggregate DMARC report with results of evaluations of the DMARC
741policy of your domain for messages received by us that have your domain in the
742message From header. You are receiving this message because your address is
743specified in the "rua" field of the DMARC record for your domain.
749`, dom, mox.Conf.Static.HostnameDomain, report.ReportMetadata.ReportID, beginTime.UTC().Format(time.DateTime), endTime.UTC().Format(time.DateTime))
752 reportFilename := fmt.Sprintf("%s!%s!%d!%d.xml.gz", mox.Conf.Static.HostnameDomain.ASCII, dom.ASCII, beginTime.Unix(), endTime.Add(-time.Second).Unix())
754 var addrs []message.NameAddress
755 for _, rcpt := range recipients {
756 addrs = append(addrs, message.NameAddress{Address: rcpt.address})
759 // Compose the message.
760 msgPrefix, has8bit, smtputf8, messageID, err := composeAggregateReport(ctx, log, msgf, from, addrs, subject, text, reportFilename, reportFile)
762 return false, fmt.Errorf("composing message with outgoing dmarc aggregate report: %v", err)
765 // Get size of message after all compression and encodings (base64 makes it big
766 // again), and go through potentials recipients (rua). If they are willing to
767 // accept the report, queue it.
768 msgInfo, err := msgf.Stat()
770 return false, fmt.Errorf("stat message with outgoing dmarc aggregate report: %v", err)
772 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
774 for _, rcpt := range recipients {
775 // If recipient is on suppression list, we won't queue the reporting message.
776 q := bstore.QueryDB[SuppressAddress](ctx, db)
777 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.address.Path().String()})
778 q.FilterGreater("Until", time.Now())
779 exists, err := q.Exists()
781 return false, fmt.Errorf("querying suppress list: %v", err)
784 log.Info("suppressing outgoing dmarc aggregate report", slog.Any("reportingaddress", rcpt.address))
788 // Only send to addresses where we don't exceed their size limit. The RFC mentions
789 // the size of the report, but then continues about the size after compression and
790 // transport encodings (i.e. gzip and the mime base64 attachment, so the intention
791 // is probably to compare against the size of the message that contains the report.
793 if rcpt.maxSize > 0 && msgSize > int64(rcpt.maxSize) {
797 qm := queue.MakeMsg(from.Path(), rcpt.address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
798 // Don't try as long as regular deliveries, and stop before we would send the
799 // delayed DSN. Though we also won't send that due to IsDMARCReport.
801 qm.IsDMARCReport = true
803 err = queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm)
806 log.Errorx("queueing message with dmarc aggregate report", err)
807 metricReportError.Inc()
809 log.Debug("dmarc aggregate report queued", slog.Any("recipient", rcpt.address))
816 if err := sendErrorReport(ctx, log, db, from, addrs, dom, report.ReportMetadata.ReportID, msgSize); err != nil {
817 log.Errorx("sending dmarc error reports", err)
818 metricReportError.Inc()
822 // Regardless of whether we queued a report, we are not going to keep the
823 // evaluations around. Though this can be overridden if tempError is set.
829func composeAggregateReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr smtp.Address, recipients []message.NameAddress, subject, text, filename string, reportXMLGzipFile *os.File) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
830 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
831 smtputf8 = fromAddr.Localpart.IsInternational()
832 for _, r := range recipients {
834 smtputf8 = r.Address.Localpart.IsInternational()
838 xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
844 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
851 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
852 xc.HeaderAddrs("To", recipients)
854 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
855 xc.Header("Message-Id", messageID)
856 xc.Header("Date", time.Now().Format(message.RFC5322Z))
857 xc.Header("User-Agent", "mox/"+moxvar.Version)
858 xc.Header("MIME-Version", "1.0")
860 // Multipart message, with a text/plain and the report attached.
861 mp := multipart.NewWriter(xc)
862 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
865 // Textual part, just mentioning this is a DMARC report.
866 textBody, ct, cte := xc.TextPart("plain", text)
867 textHdr := textproto.MIMEHeader{}
868 textHdr.Set("Content-Type", ct)
869 textHdr.Set("Content-Transfer-Encoding", cte)
870 textp, err := mp.CreatePart(textHdr)
871 xc.Checkf(err, "adding text part to message")
872 _, err = textp.Write(textBody)
873 xc.Checkf(err, "writing text part")
875 // DMARC report as attachment.
876 ahdr := textproto.MIMEHeader{}
877 ahdr.Set("Content-Type", "application/gzip")
878 ahdr.Set("Content-Transfer-Encoding", "base64")
879 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
880 ahdr.Set("Content-Disposition", cd)
881 ap, err := mp.CreatePart(ahdr)
882 xc.Checkf(err, "adding dmarc aggregate report to message")
883 wc := moxio.Base64Writer(ap)
884 _, err = io.Copy(wc, &moxio.AtReader{R: reportXMLGzipFile})
885 xc.Checkf(err, "adding attachment")
887 xc.Checkf(err, "flushing attachment")
890 xc.Checkf(err, "closing multipart")
894 msgPrefix = dkimSign(ctx, log, fromAddr, xc.SMTPUTF8, mf)
896 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
899// Though this functionality is quite underspecified, we'll do our best to send our
900// an error report in case our report is too large for all recipients.
902func sendErrorReport(ctx context.Context, log mlog.Log, db *bstore.DB, fromAddr smtp.Address, recipients []message.NameAddress, reportDomain dns.Domain, reportID string, reportMsgSize int64) error {
903 log.Debug("no reporting addresses willing to accept report given size, queuing short error message")
905 msgf, err := store.CreateMessageTemp(log, "dmarcreportmsg-out")
907 return fmt.Errorf("creating temporary message file for outgoing dmarc error report: %v", err)
909 defer store.CloseRemoveTempFile(log, msgf, "outgoing dmarc error report message")
911 var recipientStrs []string
912 for _, rcpt := range recipients {
913 recipientStrs = append(recipientStrs, rcpt.Address.String())
916 subject := fmt.Sprintf("DMARC aggregate reporting error report for %s", reportDomain.ASCII)
918 text := fmt.Sprintf(`Report-Date: %s
924`, time.Now().Format(message.RFC5322Z), reportDomain.ASCII, reportID, reportMsgSize, mox.Conf.Static.HostnameDomain.ASCII, strings.Join(recipientStrs, ","))
925 text = strings.ReplaceAll(text, "\n", "\r\n")
927 msgPrefix, has8bit, smtputf8, messageID, err := composeErrorReport(ctx, log, msgf, fromAddr, recipients, subject, text)
932 msgInfo, err := msgf.Stat()
934 return fmt.Errorf("stat message with outgoing dmarc error report: %v", err)
936 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
938 for _, rcpt := range recipients {
939 // If recipient is on suppression list, we won't queue the reporting message.
940 q := bstore.QueryDB[SuppressAddress](ctx, db)
941 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.Address.Path().String()})
942 q.FilterGreater("Until", time.Now())
943 exists, err := q.Exists()
945 return fmt.Errorf("querying suppress list: %v", err)
948 log.Info("suppressing outgoing dmarc error report", slog.Any("reportingaddress", rcpt.Address))
952 qm := queue.MakeMsg(fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
953 // Don't try as long as regular deliveries, and stop before we would send the
954 // delayed DSN. Though we also won't send that due to IsDMARCReport.
956 qm.IsDMARCReport = true
958 if err := queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm); err != nil {
959 log.Errorx("queueing message with dmarc error report", err)
960 metricReportError.Inc()
962 log.Debug("dmarc error report queued", slog.Any("recipient", rcpt))
969func composeErrorReport(ctx context.Context, log mlog.Log, mf *os.File, fromAddr smtp.Address, recipients []message.NameAddress, subject, text string) (msgPrefix string, has8bit, smtputf8 bool, messageID string, rerr error) {
970 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
971 smtputf8 = fromAddr.Localpart.IsInternational()
972 for _, r := range recipients {
974 smtputf8 = r.Address.Localpart.IsInternational()
978 xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
984 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
991 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
992 xc.HeaderAddrs("To", recipients)
993 xc.Header("Subject", subject)
994 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
995 xc.Header("Message-Id", messageID)
996 xc.Header("Date", time.Now().Format(message.RFC5322Z))
997 xc.Header("User-Agent", "mox/"+moxvar.Version)
998 xc.Header("MIME-Version", "1.0")
1000 textBody, ct, cte := xc.TextPart("plain", text)
1001 xc.Header("Content-Type", ct)
1002 xc.Header("Content-Transfer-Encoding", cte)
1004 _, err := xc.Write(textBody)
1005 xc.Checkf(err, "writing text")
1009 msgPrefix = dkimSign(ctx, log, fromAddr, smtputf8, mf)
1011 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
1014func dkimSign(ctx context.Context, log mlog.Log, fromAddr smtp.Address, smtputf8 bool, mf *os.File) string {
1015 // Add DKIM-Signature headers if we have a key for (a higher) domain than the from
1016 // address, which is a host name. A signature will only be useful with higher-level
1017 // domains if they have a relaxed dkim check (which is the default). If the dkim
1018 // check is strict, there is no harm, there will simply not be a dkim pass.
1019 fd := fromAddr.Domain
1020 var zerodom dns.Domain
1022 confDom, ok := mox.Conf.Domain(fd)
1023 selectors := mox.DKIMSelectors(confDom.DKIM)
1024 if len(selectors) > 0 {
1025 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Localpart, fd, selectors, smtputf8, mf)
1027 log.Errorx("dkim-signing dmarc report, continuing without signature", err)
1028 metricReportError.Inc()
1037 _, nfd.ASCII, _ = strings.Cut(fd.ASCII, ".")
1038 _, nfd.Unicode, _ = strings.Cut(fd.Unicode, ".")
1044// SuppressAdd adds an address to the suppress list.
1045func SuppressAdd(ctx context.Context, ba *SuppressAddress) error {
1046 return EvalDB.Insert(ctx, ba)
1049// SuppressList returns all reporting addresses on the suppress list.
1050func SuppressList(ctx context.Context) ([]SuppressAddress, error) {
1051 return bstore.QueryDB[SuppressAddress](ctx, EvalDB).SortDesc("ID").List()
1054// SuppressRemove removes a reporting address record from the suppress list.
1055func SuppressRemove(ctx context.Context, id int64) error {
1056 return EvalDB.Delete(ctx, &SuppressAddress{ID: id})
1059// SuppressUpdate updates the until field of a reporting address record.
1060func SuppressUpdate(ctx context.Context, id int64, until time.Time) error {
1061 ba := SuppressAddress{ID: id}
1062 err := EvalDB.Get(ctx, &ba)
1067 return EvalDB.Update(ctx, &ba)