3// Sending TLS reports and DMARC reports is very similar. See ../dmarcdb/eval.go:/similar and ../tlsrptsend/send.go:/similar.
25 "github.com/prometheus/client_golang/prometheus"
26 "github.com/prometheus/client_golang/prometheus/promauto"
28 "github.com/mjl-/bstore"
30 "github.com/mjl-/mox/dkim"
31 "github.com/mjl-/mox/dmarc"
32 "github.com/mjl-/mox/dmarcrpt"
33 "github.com/mjl-/mox/dns"
34 "github.com/mjl-/mox/message"
35 "github.com/mjl-/mox/metrics"
36 "github.com/mjl-/mox/mlog"
37 "github.com/mjl-/mox/mox-"
38 "github.com/mjl-/mox/moxio"
39 "github.com/mjl-/mox/moxvar"
40 "github.com/mjl-/mox/publicsuffix"
41 "github.com/mjl-/mox/queue"
42 "github.com/mjl-/mox/smtp"
43 "github.com/mjl-/mox/store"
47 metricReport = promauto.NewCounter(
48 prometheus.CounterOpts{
49 Name: "mox_dmarcdb_report_queued_total",
50 Help: "Total messages with DMARC aggregate/error reports queued.",
53 metricReportError = promauto.NewCounter(
54 prometheus.CounterOpts{
55 Name: "mox_dmarcdb_report_error_total",
56 Help: "Total errors while composing or queueing DMARC aggregate/error reports.",
62 EvalDBTypes = []any{Evaluation{}, SuppressAddress{}} // Types stored in DB.
63 // Exported for backups. For incoming deliveries the SMTP server adds evaluations
64 // to the database. Every hour, a goroutine wakes up that gathers evaluations from
65 // the last hour(s), sends a report, and removes the evaluations from the database.
69// Evaluation is the result of an evaluation of a DMARC policy, to be included
71type Evaluation struct {
74 // Domain where DMARC policy was found, could be the organizational domain while
75 // evaluation was for a subdomain. Unicode. Same as domain found in
76 // PolicyPublished. A separate field for its index.
77 PolicyDomain string `bstore:"index"`
79 // Time of evaluation, determines which report (covering whole hours) this
80 // evaluation will be included in.
81 Evaluated time.Time `bstore:"default now"`
83 // If optional, this evaluation is not a reason to send a DMARC report, but it will
84 // be included when a report is sent due to other non-optional evaluations. Set for
85 // evaluations of incoming DMARC reports. We don't want such deliveries causing us to
86 // send a report, or we would keep exchanging reporting messages forever. Also set
87 // for when evaluation is a DMARC reject for domains we haven't positively
88 // interacted with, to prevent being used to flood an unsuspecting domain with
92 // Effective aggregate reporting interval in hours. Between 1 and 24, rounded up
93 // from seconds from policy to first number that can divide 24.
96 // "rua" in DMARC record, we only store evaluations for records with aggregate reporting addresses, so always non-empty.
99 // Policy used for evaluation. We don't store the "fo" field for failure reporting
100 // options, since we don't send failure reports for individual messages.
101 PolicyPublished dmarcrpt.PolicyPublished
103 // For "row" in a report record.
105 Disposition dmarcrpt.Disposition
108 OverrideReasons []dmarcrpt.PolicyOverrideReason
110 // For "identifiers" in a report record.
115 // For "auth_results" in a report record.
116 DKIMResults []dmarcrpt.DKIMAuthResult
117 SPFResults []dmarcrpt.SPFAuthResult
120// SuppressAddress is a reporting address for which outgoing DMARC reports
121// will be suppressed for a period.
122type SuppressAddress struct {
124 Inserted time.Time `bstore:"default now"`
125 ReportingAddress string `bstore:"unique"`
126 Until time.Time `bstore:"nonzero"`
130var dmarcResults = map[bool]dmarcrpt.DMARCResult{
131 false: dmarcrpt.DMARCFail,
132 true: dmarcrpt.DMARCPass,
135// ReportRecord turns an evaluation into a record that can be included in a
137func (e Evaluation) ReportRecord(count int) dmarcrpt.ReportRecord {
138 return dmarcrpt.ReportRecord{
140 SourceIP: e.SourceIP,
142 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
143 Disposition: e.Disposition,
144 DKIM: dmarcResults[e.AlignedDKIMPass],
145 SPF: dmarcResults[e.AlignedSPFPass],
146 Reasons: e.OverrideReasons,
149 Identifiers: dmarcrpt.Identifiers{
150 EnvelopeTo: e.EnvelopeTo,
151 EnvelopeFrom: e.EnvelopeFrom,
152 HeaderFrom: e.HeaderFrom,
154 AuthResults: dmarcrpt.AuthResults{
161var intervalOpts = []int{24, 12, 8, 6, 4, 3, 2}
163func intervalHours(seconds int) int {
164 hours := (seconds + 3600 - 1) / 3600
165 for _, opt := range intervalOpts {
173// AddEvaluation adds the result of a DMARC evaluation for an incoming message
176// AddEvaluation sets Evaluation.IntervalHours based on
177// aggregateReportingIntervalSeconds.
178func AddEvaluation(ctx context.Context, aggregateReportingIntervalSeconds int, e *Evaluation) error {
179 e.IntervalHours = intervalHours(aggregateReportingIntervalSeconds)
182 return EvalDB.Insert(ctx, e)
185// Evaluations returns all evaluations in the database.
186func Evaluations(ctx context.Context) ([]Evaluation, error) {
187 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
188 q.SortAsc("Evaluated")
192// EvaluationStat summarizes stored evaluations, for inclusion in an upcoming
193// aggregate report, for a domain.
194type EvaluationStat struct {
196 Dispositions []string
201// EvaluationStats returns evaluation counts and report-sending status per domain.
202func EvaluationStats(ctx context.Context) (map[string]EvaluationStat, error) {
203 r := map[string]EvaluationStat{}
205 err := bstore.QueryDB[Evaluation](ctx, EvalDB).ForEach(func(e Evaluation) error {
206 if stat, ok := r[e.PolicyDomain]; ok {
207 if !slices.Contains(stat.Dispositions, string(e.Disposition)) {
208 stat.Dispositions = append(stat.Dispositions, string(e.Disposition))
211 stat.SendReport = stat.SendReport || !e.Optional
212 r[e.PolicyDomain] = stat
214 dom, err := dns.ParseDomain(e.PolicyDomain)
216 return fmt.Errorf("parsing domain %q: %v", e.PolicyDomain, err)
218 r[e.PolicyDomain] = EvaluationStat{
220 Dispositions: []string{string(e.Disposition)},
222 SendReport: !e.Optional,
230// EvaluationsDomain returns all evaluations for a domain.
231func EvaluationsDomain(ctx context.Context, domain dns.Domain) ([]Evaluation, error) {
232 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
233 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
234 q.SortAsc("Evaluated")
238// RemoveEvaluationsDomain removes evaluations for domain so they won't be sent in
239// an aggregate report.
240func RemoveEvaluationsDomain(ctx context.Context, domain dns.Domain) error {
241 q := bstore.QueryDB[Evaluation](ctx, EvalDB)
242 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
247var jitterRand = mox.NewPseudoRand()
249// time to sleep until next whole hour t, replaced by tests.
250// Jitter so we don't cause load at exactly whole hours, other processes may
251// already be doing that.
252var jitteredTimeUntil = func(t time.Time) time.Duration {
253 return time.Until(t.Add(time.Duration(30+jitterRand.IntN(60)) * time.Second))
256// Start launches a goroutine that wakes up at each whole hour (plus jitter) and
257// sends DMARC reports to domains that requested them.
258func Start(resolver dns.Resolver) {
260 log := mlog.New("dmarcdb", nil)
263 // In case of panic don't take the whole program down.
266 log.Error("recover from panic", slog.Any("panic", x))
268 metrics.PanicInc(metrics.Dmarcdb)
272 timer := time.NewTimer(time.Hour)
279 nextEnd := nextWholeHour(now)
280 timer.Reset(jitteredTimeUntil(nextEnd))
284 log.Info("dmarc aggregate report sender shutting down")
289 // Gather report intervals we want to process now. Multiples of hours that can
290 // divide 24, starting from UTC.
292 utchour := nextEnd.UTC().Hour()
297 for _, ival := range intervalOpts {
298 if ival*(utchour/ival) == utchour {
299 intervals = append(intervals, ival)
302 intervals = append(intervals, 1)
304 // Remove evaluations older than 48 hours (2 reports with the default and maximum
305 // 24 hour interval). They should have been processed by now. We may have kept them
306 // during temporary errors, but persistent temporary errors shouldn't fill up our
307 // database. This also cleans up evaluations that were all optional for a domain.
308 _, err := bstore.QueryDB[Evaluation](ctx, EvalDB).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
309 log.Check(err, "removing stale dmarc evaluations from database")
311 clog := log.WithCid(mox.Cid())
312 clog.Info("sending dmarc aggregate reports", slog.Time("end", nextEnd.UTC()), slog.Any("intervals", intervals))
313 if err := sendReports(ctx, clog, resolver, EvalDB, nextEnd, intervals); err != nil {
314 clog.Errorx("sending dmarc aggregate reports", err)
315 metricReportError.Inc()
317 clog.Info("finished sending dmarc aggregate reports")
323func nextWholeHour(now time.Time) time.Time {
326 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
329// We don't send reports at full speed. In the future, we could try to stretch out
330// reports a bit smarter. E.g. over 5 minutes with some minimum interval, and
331// perhaps faster and in parallel when there are lots of reports. Perhaps also
332// depending on reporting interval (faster for 1h, slower for 24h).
334var sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) {
335 t := time.NewTimer(between)
345// sendReports gathers all policy domains that have evaluations that should
346// receive a DMARC report and sends a report to each.
347func sendReports(ctx context.Context, log mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, intervals []int) error {
348 ivals := make([]any, len(intervals))
349 for i, v := range intervals {
353 destDomains := map[string]bool{}
355 // Gather all domains that we plan to send to.
357 q := bstore.QueryDB[Evaluation](ctx, db)
358 q.FilterLess("Evaluated", endTime)
359 q.FilterEqual("IntervalHours", ivals...)
360 err := q.ForEach(func(e Evaluation) error {
361 if !e.Optional && !destDomains[e.PolicyPublished.Domain] {
364 destDomains[e.PolicyPublished.Domain] = destDomains[e.PolicyPublished.Domain] || !e.Optional
368 return fmt.Errorf("looking for domains to send reports to: %v", err)
371 var wg sync.WaitGroup
373 // Sleep in between sending reports. We process hourly, and spread the reports over
374 // the hour, but with max 5 minute interval.
375 between := 45 * time.Minute
377 between /= time.Duration(nsend)
379 if between > 5*time.Minute {
380 between = 5 * time.Minute
383 // Attempt to send report to each domain.
385 for d, send := range destDomains {
386 // Cleanup evaluations for domain with only optionals.
388 removeEvaluations(ctx, log, db, endTime, d)
393 if ok := sleepBetween(ctx, between); !ok {
399 // Send in goroutine, so a slow process doesn't block progress.
401 go func(domain string) {
403 // In case of panic don't take the whole program down.
406 log.Error("unhandled panic in dmarcdb sendReports", slog.Any("panic", x))
408 metrics.PanicInc(metrics.Dmarcdb)
413 rlog := log.WithCid(mox.Cid()).With(slog.Any("domain", domain))
414 rlog.Info("sending dmarc report")
415 if _, err := sendReportDomain(ctx, rlog, resolver, db, endTime, domain); err != nil {
416 rlog.Errorx("sending dmarc aggregate report to domain", err)
417 metricReportError.Inc()
427type recipient struct {
432func parseRecipient(log mlog.Log, uri dmarc.URI) (r recipient, ok bool) {
433 log = log.With(slog.Any("uri", uri.Address))
435 u, err := url.Parse(uri.Address)
437 log.Debugx("parsing uri in dmarc record rua value", err)
440 if !strings.EqualFold(u.Scheme, "mailto") {
441 log.Debug("skipping unrecognized scheme in dmarc record rua value")
444 addr, err := smtp.ParseAddress(u.Opaque)
446 log.Debugx("parsing mailto uri in dmarc record rua value", err)
450 r = recipient{addr, uri.MaxSize}
456 r.maxSize *= 1024 * 1024
458 r.maxSize *= 1024 * 1024 * 1024
460 // Oh yeah, terabyte-sized reports!
461 r.maxSize *= 1024 * 1024 * 1024 * 1024
464 log.Debug("unrecognized max size unit in dmarc record rua value", slog.String("unit", uri.Unit))
471func removeEvaluations(ctx context.Context, log mlog.Log, db *bstore.DB, endTime time.Time, domain string) {
472 q := bstore.QueryDB[Evaluation](ctx, db)
473 q.FilterLess("Evaluated", endTime)
474 q.FilterNonzero(Evaluation{PolicyDomain: domain})
476 log.Check(err, "removing evaluations after processing for dmarc aggregate report")
479// replaceable for testing.
480var queueAdd = queue.Add
482func sendReportDomain(ctx context.Context, log mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, domain string) (cleanup bool, rerr error) {
483 dom, err := dns.ParseDomain(domain)
485 return false, fmt.Errorf("parsing domain for sending reports: %v", err)
488 // We'll cleanup records by default.
490 // If we encounter a temporary error we cancel cleanup of evaluations on error.
494 if !cleanup || tempError {
495 log.Debug("not cleaning up evaluations after attempting to send dmarc aggregate report")
497 removeEvaluations(ctx, log, db, endTime, domain)
501 // We're going to build up this report.
502 report := dmarcrpt.Feedback{
504 ReportMetadata: dmarcrpt.ReportMetadata{
505 OrgName: mox.Conf.Static.HostnameDomain.ASCII,
506 Email: "postmaster@" + mox.Conf.Static.HostnameDomain.ASCII,
507 // ReportID and DateRange are set after we've seen evaluations.
508 // Errors is filled below when we encounter problems.
510 // We'll fill the records below.
511 Records: []dmarcrpt.ReportRecord{},
514 var errors []string // For report.ReportMetaData.Errors
516 // Check if we should be sending a report at all: if there are rua URIs in the
517 // current DMARC record. The interval may have changed too, but we'll flush out our
518 // evaluations regardless. We always use the latest DMARC record when sending, but
519 // we'll lump all policies of the last interval into one report.
521 status, _, record, _, _, err := dmarc.Lookup(ctx, log.Logger, resolver, dom)
523 // 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).
524 // Remove records unless it was a temporary error. We'll try again next round.
525 cleanup = status != dmarc.StatusTemperror
526 return cleanup, fmt.Errorf("looking up current dmarc record for reporting address: %v", err)
529 var recipients []recipient
531 // Gather all aggregate reporting addresses to try to send to. We'll start with
532 // those in the initial DMARC record, but will follow external reporting addresses
533 // and possibly update the list.
534 for _, uri := range record.AggregateReportAddresses {
535 r, ok := parseRecipient(log, uri)
540 // Check if domain of rua recipient has the same organizational domain as for the
541 // evaluations. If not, we need to verify we are allowed to send.
542 ruaOrgDom := publicsuffix.Lookup(ctx, log.Logger, r.address.Domain)
543 evalOrgDom := publicsuffix.Lookup(ctx, log.Logger, dom)
545 if ruaOrgDom == evalOrgDom {
546 recipients = append(recipients, r)
550 // Verify and follow addresses in other organizational domain through
551 // <policydomain>._report._dmarc.<host> lookup.
553 accepts, status, records, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, log.Logger, resolver, evalOrgDom, r.address.Domain)
554 log.Debugx("checking if rua address with different organization domain has opted into receiving dmarc reports", err,
555 slog.Any("policydomain", evalOrgDom),
556 slog.Any("destinationdomain", r.address.Domain),
557 slog.Bool("accepts", accepts),
558 slog.Any("status", status))
559 if status == dmarc.StatusTemperror {
560 // With a temporary error, we'll try to get the report the delivered anyway,
561 // perhaps there are multiple recipients.
564 errors = append(errors, "temporary error checking authorization for report delegation to external address")
567 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))
571 // We can follow a _report DMARC DNS record once. In that record, a domain may
572 // specify alternative addresses that we should send reports to instead. Such
573 // alternative address(es) must have the same host. If not, we ignore the new
574 // value. Behaviour for multiple records and/or multiple new addresses is
575 // underspecified. We'll replace an address with one or more new addresses, and
576 // keep the original if there was no candidate (which covers the case of invalid
577 // alternative addresses and no new address specified).
579 foundReplacement := false
580 rlog := log.With(slog.Any("followedaddress", uri.Address))
581 for _, record := range records {
582 for _, exturi := range record.AggregateReportAddresses {
583 extr, ok := parseRecipient(rlog, exturi)
587 if extr.address.Domain != r.address.Domain {
588 rlog.Debug("rua address in external _report dmarc record has different host than initial dmarc record, ignoring new name", slog.Any("externaladdress", extr.address))
589 errors = append(errors, fmt.Sprintf("rua %s is external domain with a replacement address %s with different host", r.address, extr.address))
591 rlog.Debug("using replacement rua address from external _report dmarc record", slog.Any("externaladdress", extr.address))
592 foundReplacement = true
593 recipients = append(recipients, extr)
597 if !foundReplacement {
598 recipients = append(recipients, r)
602 if len(recipients) == 0 {
603 // No reports requested, perfectly fine, no work to do for us.
604 log.Debug("no aggregate reporting addresses configured")
608 // We count idential records. Can be common with a domain sending quite some email.
609 // Though less if the sending domain has many IPs. In the future, we may want to
610 // remove some details from records so we can aggregate them into fewer rows.
611 type recordCount struct {
612 dmarcrpt.ReportRecord
615 counts := map[string]recordCount{}
617 var first, last Evaluation // For daterange.
620 q := bstore.QueryDB[Evaluation](ctx, db)
621 q.FilterLess("Evaluated", endTime)
622 q.FilterNonzero(Evaluation{PolicyDomain: domain})
623 q.SortAsc("Evaluated")
624 err = q.ForEach(func(e Evaluation) error {
630 record := e.ReportRecord(0)
632 // 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.
634 // Simple but inefficient way to aggregate identical records. We may want to turn
635 // records into smaller representation in the future.
636 recbuf, err := xml.Marshal(record)
638 return fmt.Errorf("xml marshal of report record: %v", err)
640 recstr := string(recbuf)
641 counts[recstr] = recordCount{record, counts[recstr].count + 1}
648 return false, fmt.Errorf("gathering evaluations for report: %v", err)
652 log.Debug("no non-optional evaluations for domain, not sending dmarc aggregate report")
656 // Set begin and end date range. We try to set it to whole intervals as requested
657 // by the domain owner. The typical, default and maximum interval is 24 hours. But
658 // we allow any whole number of hours that can divide 24 hours. If we have an
659 // evaluation that is older, we may have had a failure to send earlier. We include
660 // those earlier intervals in this report as well.
662 // Although "end" could be interpreted as exclusive, to be on the safe side
663 // regarding client behaviour, and (related) to mimic large existing DMARC report
664 // senders, we set it to the last second of the period this report covers.
665 report.ReportMetadata.DateRange.End = endTime.Add(-time.Second).Unix()
666 interval := time.Duration(first.IntervalHours) * time.Hour
667 beginTime := endTime.Add(-interval)
668 for first.Evaluated.Before(beginTime) {
669 beginTime = beginTime.Add(-interval)
671 report.ReportMetadata.DateRange.Begin = beginTime.Unix()
673 // yyyymmddHH, we only send one report per hour, so should be unique per policy
674 // domain. We also add a truly unique id based on first evaluation id used without
675 // revealing the number of evaluations we have. Reuse of ReceivedID is not great,
676 // but shouldn't hurt.
677 report.ReportMetadata.ReportID = endTime.UTC().Format("20060102.15") + "." + mox.ReceivedID(first.ID)
679 // We may include errors we encountered when composing the report. We
680 // don't currently include errors about dmarc evaluations, e.g. DNS
681 // lookup errors during incoming deliveries.
682 report.ReportMetadata.Errors = errors
684 // We'll fill this with the last-used record, not the one we fetch fresh from DSN.
685 // They will almost always be the same, but if not, the fresh record was never
686 // actually used for evaluations, so no point in reporting it.
687 report.PolicyPublished = last.PolicyPublished
689 // Process records in-order for testable results.
690 for _, recstr := range slices.Sorted(maps.Keys(counts)) {
692 rc.ReportRecord.Row.Count = rc.count
693 report.Records = append(report.Records, rc.ReportRecord)
696 reportFile, err := store.CreateMessageTemp(log, "dmarcreportout")
698 return false, fmt.Errorf("creating temporary file for outgoing dmarc aggregate report: %v", err)
700 defer store.CloseRemoveTempFile(log, reportFile, "generated dmarc aggregate report")
702 gzw := gzip.NewWriter(reportFile)
703 _, err = fmt.Fprint(gzw, xml.Header)
704 enc := xml.NewEncoder(gzw)
705 enc.Indent("", "\t") // Keep up pretention that xml is human-readable.
707 err = enc.Encode(report)
716 return true, fmt.Errorf("writing dmarc aggregate report as xml with gzip: %v", err)
719 msgf, err := store.CreateMessageTemp(log, "dmarcreportmsgout")
721 return false, fmt.Errorf("creating temporary message file with outgoing dmarc aggregate report: %v", err)
723 defer store.CloseRemoveTempFile(log, msgf, "message with generated dmarc aggregate report")
725 // We are sending reports from our host's postmaster address. In a
726 // typical setup the host is a subdomain of a configured domain with
727 // DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
728 // A single report can contain deliveries from a single policy domain
729 // to multiple of our configured domains.
730 from := smtp.NewAddress("postmaster", mox.Conf.Static.HostnameDomain)
733 subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", dom.ASCII, mox.Conf.Static.HostnameDomain.ASCII, report.ReportMetadata.ReportID)
736 text := fmt.Sprintf(`Attached is an aggregate DMARC report with results of evaluations of the DMARC
737policy of your domain for messages received by us that have your domain in the
738message From header. You are receiving this message because your address is
739specified in the "rua" field of the DMARC record for your domain.
745`, dom, mox.Conf.Static.HostnameDomain, report.ReportMetadata.ReportID, beginTime.UTC().Format(time.DateTime), endTime.UTC().Format(time.DateTime))
748 reportFilename := fmt.Sprintf("%s!%s!%d!%d.xml.gz", mox.Conf.Static.HostnameDomain.ASCII, dom.ASCII, beginTime.Unix(), endTime.Add(-time.Second).Unix())
750 var addrs []message.NameAddress
751 for _, rcpt := range recipients {
752 addrs = append(addrs, message.NameAddress{Address: rcpt.address})
755 // Compose the message.
756 msgPrefix, has8bit, smtputf8, messageID, err := composeAggregateReport(ctx, log, msgf, from, addrs, subject, text, reportFilename, reportFile)
758 return false, fmt.Errorf("composing message with outgoing dmarc aggregate report: %v", err)
761 // Get size of message after all compression and encodings (base64 makes it big
762 // again), and go through potentials recipients (rua). If they are willing to
763 // accept the report, queue it.
764 msgInfo, err := msgf.Stat()
766 return false, fmt.Errorf("stat message with outgoing dmarc aggregate report: %v", err)
768 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
770 for _, rcpt := range recipients {
771 // If recipient is on suppression list, we won't queue the reporting message.
772 q := bstore.QueryDB[SuppressAddress](ctx, db)
773 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.address.Path().String()})
774 q.FilterGreater("Until", time.Now())
775 exists, err := q.Exists()
777 return false, fmt.Errorf("querying suppress list: %v", err)
780 log.Info("suppressing outgoing dmarc aggregate report", slog.Any("reportingaddress", rcpt.address))
784 // Only send to addresses where we don't exceed their size limit. The RFC mentions
785 // the size of the report, but then continues about the size after compression and
786 // transport encodings (i.e. gzip and the mime base64 attachment, so the intention
787 // is probably to compare against the size of the message that contains the report.
789 if rcpt.maxSize > 0 && msgSize > int64(rcpt.maxSize) {
793 qm := queue.MakeMsg(from.Path(), rcpt.address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
794 // Don't try as long as regular deliveries, and stop before we would send the
795 // delayed DSN. Though we also won't send that due to IsDMARCReport.
797 qm.IsDMARCReport = true
799 err = queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm)
802 log.Errorx("queueing message with dmarc aggregate report", err)
803 metricReportError.Inc()
805 log.Debug("dmarc aggregate report queued", slog.Any("recipient", rcpt.address))
812 if err := sendErrorReport(ctx, log, db, from, addrs, dom, report.ReportMetadata.ReportID, msgSize); err != nil {
813 log.Errorx("sending dmarc error reports", err)
814 metricReportError.Inc()
818 // Regardless of whether we queued a report, we are not going to keep the
819 // evaluations around. Though this can be overridden if tempError is set.
825func 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) {
826 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
827 smtputf8 = fromAddr.Localpart.IsInternational()
828 for _, r := range recipients {
830 smtputf8 = r.Address.Localpart.IsInternational()
834 xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
840 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
847 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
848 xc.HeaderAddrs("To", recipients)
850 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
851 xc.Header("Message-Id", messageID)
852 xc.Header("Date", time.Now().Format(message.RFC5322Z))
853 xc.Header("User-Agent", "mox/"+moxvar.Version)
854 xc.Header("MIME-Version", "1.0")
856 // Multipart message, with a text/plain and the report attached.
857 mp := multipart.NewWriter(xc)
858 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
861 // Textual part, just mentioning this is a DMARC report.
862 textBody, ct, cte := xc.TextPart("plain", text)
863 textHdr := textproto.MIMEHeader{}
864 textHdr.Set("Content-Type", ct)
865 textHdr.Set("Content-Transfer-Encoding", cte)
866 textp, err := mp.CreatePart(textHdr)
867 xc.Checkf(err, "adding text part to message")
868 _, err = textp.Write(textBody)
869 xc.Checkf(err, "writing text part")
871 // DMARC report as attachment.
872 ahdr := textproto.MIMEHeader{}
873 ahdr.Set("Content-Type", "application/gzip")
874 ahdr.Set("Content-Transfer-Encoding", "base64")
875 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
876 ahdr.Set("Content-Disposition", cd)
877 ap, err := mp.CreatePart(ahdr)
878 xc.Checkf(err, "adding dmarc aggregate report to message")
879 wc := moxio.Base64Writer(ap)
880 _, err = io.Copy(wc, &moxio.AtReader{R: reportXMLGzipFile})
881 xc.Checkf(err, "adding attachment")
883 xc.Checkf(err, "flushing attachment")
886 xc.Checkf(err, "closing multipart")
890 msgPrefix = dkimSign(ctx, log, fromAddr, xc.SMTPUTF8, mf)
892 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
895// Though this functionality is quite underspecified, we'll do our best to send our
896// an error report in case our report is too large for all recipients.
898func sendErrorReport(ctx context.Context, log mlog.Log, db *bstore.DB, fromAddr smtp.Address, recipients []message.NameAddress, reportDomain dns.Domain, reportID string, reportMsgSize int64) error {
899 log.Debug("no reporting addresses willing to accept report given size, queuing short error message")
901 msgf, err := store.CreateMessageTemp(log, "dmarcreportmsg-out")
903 return fmt.Errorf("creating temporary message file for outgoing dmarc error report: %v", err)
905 defer store.CloseRemoveTempFile(log, msgf, "outgoing dmarc error report message")
907 var recipientStrs []string
908 for _, rcpt := range recipients {
909 recipientStrs = append(recipientStrs, rcpt.Address.String())
912 subject := fmt.Sprintf("DMARC aggregate reporting error report for %s", reportDomain.ASCII)
914 text := fmt.Sprintf(`Report-Date: %s
920`, time.Now().Format(message.RFC5322Z), reportDomain.ASCII, reportID, reportMsgSize, mox.Conf.Static.HostnameDomain.ASCII, strings.Join(recipientStrs, ","))
921 text = strings.ReplaceAll(text, "\n", "\r\n")
923 msgPrefix, has8bit, smtputf8, messageID, err := composeErrorReport(ctx, log, msgf, fromAddr, recipients, subject, text)
928 msgInfo, err := msgf.Stat()
930 return fmt.Errorf("stat message with outgoing dmarc error report: %v", err)
932 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
934 for _, rcpt := range recipients {
935 // If recipient is on suppression list, we won't queue the reporting message.
936 q := bstore.QueryDB[SuppressAddress](ctx, db)
937 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.Address.Path().String()})
938 q.FilterGreater("Until", time.Now())
939 exists, err := q.Exists()
941 return fmt.Errorf("querying suppress list: %v", err)
944 log.Info("suppressing outgoing dmarc error report", slog.Any("reportingaddress", rcpt.Address))
948 qm := queue.MakeMsg(fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil, time.Now(), subject)
949 // Don't try as long as regular deliveries, and stop before we would send the
950 // delayed DSN. Though we also won't send that due to IsDMARCReport.
952 qm.IsDMARCReport = true
954 if err := queueAdd(ctx, log, mox.Conf.Static.Postmaster.Account, msgf, qm); err != nil {
955 log.Errorx("queueing message with dmarc error report", err)
956 metricReportError.Inc()
958 log.Debug("dmarc error report queued", slog.Any("recipient", rcpt))
965func 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) {
966 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
967 smtputf8 = fromAddr.Localpart.IsInternational()
968 for _, r := range recipients {
970 smtputf8 = r.Address.Localpart.IsInternational()
974 xc := message.NewComposer(mf, 100*1024*1024, smtputf8)
980 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
987 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
988 xc.HeaderAddrs("To", recipients)
989 xc.Header("Subject", subject)
990 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
991 xc.Header("Message-Id", messageID)
992 xc.Header("Date", time.Now().Format(message.RFC5322Z))
993 xc.Header("User-Agent", "mox/"+moxvar.Version)
994 xc.Header("MIME-Version", "1.0")
996 textBody, ct, cte := xc.TextPart("plain", text)
997 xc.Header("Content-Type", ct)
998 xc.Header("Content-Transfer-Encoding", cte)
1000 _, err := xc.Write(textBody)
1001 xc.Checkf(err, "writing text")
1005 msgPrefix = dkimSign(ctx, log, fromAddr, smtputf8, mf)
1007 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
1010func dkimSign(ctx context.Context, log mlog.Log, fromAddr smtp.Address, smtputf8 bool, mf *os.File) string {
1011 // Add DKIM-Signature headers if we have a key for (a higher) domain than the from
1012 // address, which is a host name. A signature will only be useful with higher-level
1013 // domains if they have a relaxed dkim check (which is the default). If the dkim
1014 // check is strict, there is no harm, there will simply not be a dkim pass.
1015 fd := fromAddr.Domain
1016 var zerodom dns.Domain
1018 confDom, ok := mox.Conf.Domain(fd)
1019 selectors := mox.DKIMSelectors(confDom.DKIM)
1020 if len(selectors) > 0 && !confDom.Disabled {
1021 dkimHeaders, err := dkim.Sign(ctx, log.Logger, fromAddr.Localpart, fd, selectors, smtputf8, mf)
1023 log.Errorx("dkim-signing dmarc report, continuing without signature", err)
1024 metricReportError.Inc()
1033 _, nfd.ASCII, _ = strings.Cut(fd.ASCII, ".")
1034 _, nfd.Unicode, _ = strings.Cut(fd.Unicode, ".")
1040// SuppressAdd adds an address to the suppress list.
1041func SuppressAdd(ctx context.Context, ba *SuppressAddress) error {
1042 return EvalDB.Insert(ctx, ba)
1045// SuppressList returns all reporting addresses on the suppress list.
1046func SuppressList(ctx context.Context) ([]SuppressAddress, error) {
1047 return bstore.QueryDB[SuppressAddress](ctx, EvalDB).SortDesc("ID").List()
1050// SuppressRemove removes a reporting address record from the suppress list.
1051func SuppressRemove(ctx context.Context, id int64) error {
1052 return EvalDB.Delete(ctx, &SuppressAddress{ID: id})
1055// SuppressUpdate updates the until field of a reporting address record.
1056func SuppressUpdate(ctx context.Context, id int64, until time.Time) error {
1057 ba := SuppressAddress{ID: id}
1058 err := EvalDB.Get(ctx, &ba)
1063 return EvalDB.Update(ctx, &ba)