3// Sending TLS reports and DMARC reports is very similar. See ../dmarcdb/eval.go:/similar and ../tlsrptsend/send.go:/similar.
24 "golang.org/x/exp/maps"
25 "golang.org/x/exp/slices"
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.
72// Evaluation is the result of an evaluation of a DMARC policy, to be included
74type Evaluation struct {
77 // Domain where DMARC policy was found, could be the organizational domain while
78 // evaluation was for a subdomain. Unicode. Same as domain found in
79 // PolicyPublished. A separate field for its index.
80 PolicyDomain string `bstore:"index"`
82 // Time of evaluation, determines which report (covering whole hours) this
83 // evaluation will be included in.
84 Evaluated time.Time `bstore:"default now"`
86 // If optional, this evaluation is not a reason to send a DMARC report, but it will
87 // be included when a report is sent due to other non-optional evaluations. Set for
88 // evaluations of incoming DMARC reports. We don't want such deliveries causing us to
89 // send a report, or we would keep exchanging reporting messages forever. Also set
90 // for when evaluation is a DMARC reject for domains we haven't positively
91 // interacted with, to prevent being used to flood an unsuspecting domain with
95 // Effective aggregate reporting interval in hours. Between 1 and 24, rounded up
96 // from seconds from policy to first number that can divide 24.
99 // "rua" in DMARC record, we only store evaluations for records with aggregate reporting addresses, so always non-empty.
102 // Policy used for evaluation. We don't store the "fo" field for failure reporting
103 // options, since we don't send failure reports for individual messages.
104 PolicyPublished dmarcrpt.PolicyPublished
106 // For "row" in a report record.
108 Disposition dmarcrpt.Disposition
111 OverrideReasons []dmarcrpt.PolicyOverrideReason
113 // For "identifiers" in a report record.
118 // For "auth_results" in a report record.
119 DKIMResults []dmarcrpt.DKIMAuthResult
120 SPFResults []dmarcrpt.SPFAuthResult
123// SuppressAddress is a reporting address for which outgoing DMARC reports
124// will be suppressed for a period.
125type SuppressAddress struct {
127 Inserted time.Time `bstore:"default now"`
128 ReportingAddress string `bstore:"unique"`
129 Until time.Time `bstore:"nonzero"`
133var dmarcResults = map[bool]dmarcrpt.DMARCResult{
134 false: dmarcrpt.DMARCFail,
135 true: dmarcrpt.DMARCPass,
138// ReportRecord turns an evaluation into a record that can be included in a
140func (e Evaluation) ReportRecord(count int) dmarcrpt.ReportRecord {
141 return dmarcrpt.ReportRecord{
143 SourceIP: e.SourceIP,
145 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
146 Disposition: e.Disposition,
147 DKIM: dmarcResults[e.AlignedDKIMPass],
148 SPF: dmarcResults[e.AlignedSPFPass],
149 Reasons: e.OverrideReasons,
152 Identifiers: dmarcrpt.Identifiers{
153 EnvelopeTo: e.EnvelopeTo,
154 EnvelopeFrom: e.EnvelopeFrom,
155 HeaderFrom: e.HeaderFrom,
157 AuthResults: dmarcrpt.AuthResults{
164func evalDB(ctx context.Context) (rdb *bstore.DB, rerr error) {
166 defer evalMutex.Unlock()
168 p := mox.DataDirPath("dmarceval.db")
169 os.MkdirAll(filepath.Dir(p), 0770)
170 db, err := bstore.Open(ctx, p, &bstore.Options{Timeout: 5 * time.Second, Perm: 0660}, EvalDBTypes...)
179var intervalOpts = []int{24, 12, 8, 6, 4, 3, 2}
181func intervalHours(seconds int) int {
182 hours := (seconds + 3600 - 1) / 3600
183 for _, opt := range intervalOpts {
191// AddEvaluation adds the result of a DMARC evaluation for an incoming message
194// AddEvaluation sets Evaluation.IntervalHours based on
195// aggregateReportingIntervalSeconds.
196func AddEvaluation(ctx context.Context, aggregateReportingIntervalSeconds int, e *Evaluation) error {
197 e.IntervalHours = intervalHours(aggregateReportingIntervalSeconds)
199 db, err := evalDB(ctx)
205 return db.Insert(ctx, e)
208// Evaluations returns all evaluations in the database.
209func Evaluations(ctx context.Context) ([]Evaluation, error) {
210 db, err := evalDB(ctx)
215 q := bstore.QueryDB[Evaluation](ctx, db)
216 q.SortAsc("Evaluated")
220// EvaluationStat summarizes stored evaluations, for inclusion in an upcoming
221// aggregate report, for a domain.
222type EvaluationStat struct {
224 Dispositions []string
229// EvaluationStats returns evaluation counts and report-sending status per domain.
230func EvaluationStats(ctx context.Context) (map[string]EvaluationStat, error) {
231 db, err := evalDB(ctx)
236 r := map[string]EvaluationStat{}
238 err = bstore.QueryDB[Evaluation](ctx, db).ForEach(func(e Evaluation) error {
239 if stat, ok := r[e.PolicyDomain]; ok {
240 if !slices.Contains(stat.Dispositions, string(e.Disposition)) {
241 stat.Dispositions = append(stat.Dispositions, string(e.Disposition))
244 stat.SendReport = stat.SendReport || !e.Optional
245 r[e.PolicyDomain] = stat
247 dom, err := dns.ParseDomain(e.PolicyDomain)
249 return fmt.Errorf("parsing domain %q: %v", e.PolicyDomain, err)
251 r[e.PolicyDomain] = EvaluationStat{
253 Dispositions: []string{string(e.Disposition)},
255 SendReport: !e.Optional,
263// EvaluationsDomain returns all evaluations for a domain.
264func EvaluationsDomain(ctx context.Context, domain dns.Domain) ([]Evaluation, error) {
265 db, err := evalDB(ctx)
270 q := bstore.QueryDB[Evaluation](ctx, db)
271 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
272 q.SortAsc("Evaluated")
276// RemoveEvaluationsDomain removes evaluations for domain so they won't be sent in
277// an aggregate report.
278func RemoveEvaluationsDomain(ctx context.Context, domain dns.Domain) error {
279 db, err := evalDB(ctx)
284 q := bstore.QueryDB[Evaluation](ctx, db)
285 q.FilterNonzero(Evaluation{PolicyDomain: domain.Name()})
290var jitterRand = mox.NewPseudoRand()
292// time to sleep until next whole hour t, replaced by tests.
293// Jitter so we don't cause load at exactly whole hours, other processes may
294// already be doing that.
295var jitteredTimeUntil = func(t time.Time) time.Duration {
296 return time.Until(t.Add(time.Duration(30+jitterRand.Intn(60)) * time.Second))
299// Start launches a goroutine that wakes up at each whole hour (plus jitter) and
300// sends DMARC reports to domains that requested them.
301func Start(resolver dns.Resolver) {
303 log := mlog.New("dmarcdb")
306 // In case of panic don't take the whole program down.
309 log.Error("recover from panic", mlog.Field("panic", x))
311 metrics.PanicInc(metrics.Dmarcdb)
315 timer := time.NewTimer(time.Hour)
320 db, err := evalDB(ctx)
322 log.Errorx("opening dmarc evaluations database for sending dmarc aggregate reports, not sending reports", err)
328 nextEnd := nextWholeHour(now)
329 timer.Reset(jitteredTimeUntil(nextEnd))
333 log.Info("dmarc aggregate report sender shutting down")
338 // Gather report intervals we want to process now. Multiples of hours that can
339 // divide 24, starting from UTC.
341 utchour := nextEnd.UTC().Hour()
346 for _, ival := range intervalOpts {
347 if ival*(utchour/ival) == utchour {
348 intervals = append(intervals, ival)
351 intervals = append(intervals, 1)
353 // Remove evaluations older than 48 hours (2 reports with the default and maximum
354 // 24 hour interval). They should have been processed by now. We may have kept them
355 // during temporary errors, but persistent temporary errors shouldn't fill up our
356 // database. This also cleans up evaluations that were all optional for a domain.
357 _, err := bstore.QueryDB[Evaluation](ctx, db).FilterLess("Evaluated", nextEnd.Add(-48*time.Hour)).Delete()
358 log.Check(err, "removing stale dmarc evaluations from database")
360 clog := log.WithCid(mox.Cid())
361 clog.Info("sending dmarc aggregate reports", mlog.Field("end", nextEnd.UTC()), mlog.Field("intervals", intervals))
362 if err := sendReports(ctx, clog, resolver, db, nextEnd, intervals); err != nil {
363 clog.Errorx("sending dmarc aggregate reports", err)
364 metricReportError.Inc()
366 clog.Info("finished sending dmarc aggregate reports")
372func nextWholeHour(now time.Time) time.Time {
375 return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
378// We don't send reports at full speed. In the future, we could try to stretch out
379// reports a bit smarter. E.g. over 5 minutes with some minimum interval, and
380// perhaps faster and in parallel when there are lots of reports. Perhaps also
381// depending on reporting interval (faster for 1h, slower for 24h).
383var sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) {
384 t := time.NewTimer(between)
394// sendReports gathers all policy domains that have evaluations that should
395// receive a DMARC report and sends a report to each.
396func sendReports(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, intervals []int) error {
397 ivals := make([]any, len(intervals))
398 for i, v := range intervals {
402 destDomains := map[string]bool{}
404 // Gather all domains that we plan to send to.
406 q := bstore.QueryDB[Evaluation](ctx, db)
407 q.FilterLess("Evaluated", endTime)
408 q.FilterEqual("IntervalHours", ivals...)
409 err := q.ForEach(func(e Evaluation) error {
410 if !e.Optional && !destDomains[e.PolicyPublished.Domain] {
413 destDomains[e.PolicyPublished.Domain] = destDomains[e.PolicyPublished.Domain] || !e.Optional
417 return fmt.Errorf("looking for domains to send reports to: %v", err)
420 var wg sync.WaitGroup
422 // Sleep in between sending reports. We process hourly, and spread the reports over
423 // the hour, but with max 5 minute interval.
424 between := 45 * time.Minute
426 between /= time.Duration(nsend)
428 if between > 5*time.Minute {
429 between = 5 * time.Minute
432 // Attempt to send report to each domain.
434 for d, send := range destDomains {
435 // Cleanup evaluations for domain with only optionals.
437 removeEvaluations(ctx, log, db, endTime, d)
442 if ok := sleepBetween(ctx, between); !ok {
448 // Send in goroutine, so a slow process doesn't block progress.
450 go func(domain string) {
452 // In case of panic don't take the whole program down.
455 log.Error("unhandled panic in dmarcdb sendReports", mlog.Field("panic", x))
457 metrics.PanicInc(metrics.Dmarcdb)
462 rlog := log.WithCid(mox.Cid()).Fields(mlog.Field("domain", domain))
463 rlog.Info("sending dmarc report")
464 if _, err := sendReportDomain(ctx, rlog, resolver, db, endTime, domain); err != nil {
465 rlog.Errorx("sending dmarc aggregate report to domain", err)
466 metricReportError.Inc()
476type recipient struct {
481func parseRecipient(log *mlog.Log, uri dmarc.URI) (r recipient, ok bool) {
482 log = log.Fields(mlog.Field("uri", uri.Address))
484 u, err := url.Parse(uri.Address)
486 log.Debugx("parsing uri in dmarc record rua value", err)
489 if !strings.EqualFold(u.Scheme, "mailto") {
490 log.Debug("skipping unrecognized scheme in dmarc record rua value")
493 addr, err := smtp.ParseAddress(u.Opaque)
495 log.Debugx("parsing mailto uri in dmarc record rua value", err)
499 r = recipient{addr, uri.MaxSize}
505 r.maxSize *= 1024 * 1024
507 r.maxSize *= 1024 * 1024 * 1024
509 // Oh yeah, terabyte-sized reports!
510 r.maxSize *= 1024 * 1024 * 1024 * 1024
513 log.Debug("unrecognized max size unit in dmarc record rua value", mlog.Field("unit", uri.Unit))
520func removeEvaluations(ctx context.Context, log *mlog.Log, db *bstore.DB, endTime time.Time, domain string) {
521 q := bstore.QueryDB[Evaluation](ctx, db)
522 q.FilterLess("Evaluated", endTime)
523 q.FilterNonzero(Evaluation{PolicyDomain: domain})
525 log.Check(err, "removing evaluations after processing for dmarc aggregate report")
528// replaceable for testing.
529var queueAdd = queue.Add
531func sendReportDomain(ctx context.Context, log *mlog.Log, resolver dns.Resolver, db *bstore.DB, endTime time.Time, domain string) (cleanup bool, rerr error) {
532 dom, err := dns.ParseDomain(domain)
534 return false, fmt.Errorf("parsing domain for sending reports: %v", err)
537 // We'll cleanup records by default.
539 // If we encounter a temporary error we cancel cleanup of evaluations on error.
543 if !cleanup || tempError {
544 log.Debug("not cleaning up evaluations after attempting to send dmarc aggregate report")
546 removeEvaluations(ctx, log, db, endTime, domain)
550 // We're going to build up this report.
551 report := dmarcrpt.Feedback{
553 ReportMetadata: dmarcrpt.ReportMetadata{
554 OrgName: mox.Conf.Static.HostnameDomain.ASCII,
555 Email: "postmaster@" + mox.Conf.Static.HostnameDomain.ASCII,
556 // ReportID and DateRange are set after we've seen evaluations.
557 // Errors is filled below when we encounter problems.
559 // We'll fill the records below.
560 Records: []dmarcrpt.ReportRecord{},
563 var errors []string // For report.ReportMetaData.Errors
565 // Check if we should be sending a report at all: if there are rua URIs in the
566 // current DMARC record. The interval may have changed too, but we'll flush out our
567 // evaluations regardless. We always use the latest DMARC record when sending, but
568 // we'll lump all policies of the last interval into one report.
570 status, _, record, _, _, err := dmarc.Lookup(ctx, resolver, dom)
572 // 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).
573 // Remove records unless it was a temporary error. We'll try again next round.
574 cleanup = status != dmarc.StatusTemperror
575 return cleanup, fmt.Errorf("looking up current dmarc record for reporting address: %v", err)
578 var recipients []recipient
580 // Gather all aggregate reporting addresses to try to send to. We'll start with
581 // those in the initial DMARC record, but will follow external reporting addresses
582 // and possibly update the list.
583 for _, uri := range record.AggregateReportAddresses {
584 r, ok := parseRecipient(log, uri)
589 // Check if domain of rua recipient has the same organizational domain as for the
590 // evaluations. If not, we need to verify we are allowed to send.
591 ruaOrgDom := publicsuffix.Lookup(ctx, r.address.Domain)
592 evalOrgDom := publicsuffix.Lookup(ctx, dom)
594 if ruaOrgDom == evalOrgDom {
595 recipients = append(recipients, r)
599 // Verify and follow addresses in other organizational domain through
600 // <policydomain>._report._dmarc.<host> lookup.
602 accepts, status, records, _, _, err := dmarc.LookupExternalReportsAccepted(ctx, resolver, evalOrgDom, r.address.Domain)
603 log.Debugx("checking if rua address with different organization domain has opted into receiving dmarc reports", err,
604 mlog.Field("policydomain", evalOrgDom),
605 mlog.Field("destinationdomain", r.address.Domain),
606 mlog.Field("accepts", accepts),
607 mlog.Field("status", status))
608 if status == dmarc.StatusTemperror {
609 // With a temporary error, we'll try to get the report the delivered anyway,
610 // perhaps there are multiple recipients.
613 errors = append(errors, "temporary error checking authorization for report delegation to external address")
616 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))
620 // We can follow a _report DMARC DNS record once. In that record, a domain may
621 // specify alternative addresses that we should send reports to instead. Such
622 // alternative address(es) must have the same host. If not, we ignore the new
623 // value. Behaviour for multiple records and/or multiple new addresses is
624 // underspecified. We'll replace an address with one or more new addresses, and
625 // keep the original if there was no candidate (which covers the case of invalid
626 // alternative addresses and no new address specified).
628 foundReplacement := false
629 rlog := log.Fields(mlog.Field("followedaddress", uri.Address))
630 for _, record := range records {
631 for _, exturi := range record.AggregateReportAddresses {
632 extr, ok := parseRecipient(rlog, exturi)
636 if extr.address.Domain != r.address.Domain {
637 rlog.Debug("rua address in external _report dmarc record has different host than initial dmarc record, ignoring new name", mlog.Field("externaladdress", extr.address))
638 errors = append(errors, fmt.Sprintf("rua %s is external domain with a replacement address %s with different host", r.address, extr.address))
640 rlog.Debug("using replacement rua address from external _report dmarc record", mlog.Field("externaladdress", extr.address))
641 foundReplacement = true
642 recipients = append(recipients, extr)
646 if !foundReplacement {
647 recipients = append(recipients, r)
651 if len(recipients) == 0 {
652 // No reports requested, perfectly fine, no work to do for us.
653 log.Debug("no aggregate reporting addresses configured")
657 // We count idential records. Can be common with a domain sending quite some email.
658 // Though less if the sending domain has many IPs. In the future, we may want to
659 // remove some details from records so we can aggregate them into fewer rows.
660 type recordCount struct {
661 dmarcrpt.ReportRecord
664 counts := map[string]recordCount{}
666 var first, last Evaluation // For daterange.
669 q := bstore.QueryDB[Evaluation](ctx, db)
670 q.FilterLess("Evaluated", endTime)
671 q.FilterNonzero(Evaluation{PolicyDomain: domain})
672 q.SortAsc("Evaluated")
673 err = q.ForEach(func(e Evaluation) error {
679 record := e.ReportRecord(0)
681 // 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.
683 // Simple but inefficient way to aggregate identical records. We may want to turn
684 // records into smaller representation in the future.
685 recbuf, err := xml.Marshal(record)
687 return fmt.Errorf("xml marshal of report record: %v", err)
689 recstr := string(recbuf)
690 counts[recstr] = recordCount{record, counts[recstr].count + 1}
697 return false, fmt.Errorf("gathering evaluations for report: %v", err)
701 log.Debug("no non-optional evaluations for domain, not sending dmarc aggregate report")
705 // Set begin and end date range. We try to set it to whole intervals as requested
706 // by the domain owner. The typical, default and maximum interval is 24 hours. But
707 // we allow any whole number of hours that can divide 24 hours. If we have an
708 // evaluation that is older, we may have had a failure to send earlier. We include
709 // those earlier intervals in this report as well.
711 // Although "end" could be interpreted as exclusive, to be on the safe side
712 // regarding client behaviour, and (related) to mimic large existing DMARC report
713 // senders, we set it to the last second of the period this report covers.
714 report.ReportMetadata.DateRange.End = endTime.Add(-time.Second).Unix()
715 interval := time.Duration(first.IntervalHours) * time.Hour
716 beginTime := endTime.Add(-interval)
717 for first.Evaluated.Before(beginTime) {
718 beginTime = beginTime.Add(-interval)
720 report.ReportMetadata.DateRange.Begin = beginTime.Unix()
722 // yyyymmddHH, we only send one report per hour, so should be unique per policy
723 // domain. We also add a truly unique id based on first evaluation id used without
724 // revealing the number of evaluations we have. Reuse of ReceivedID is not great,
725 // but shouldn't hurt.
726 report.ReportMetadata.ReportID = endTime.UTC().Format("20060102.15") + "." + mox.ReceivedID(first.ID)
728 // We may include errors we encountered when composing the report. We
729 // don't currently include errors about dmarc evaluations, e.g. DNS
730 // lookup errors during incoming deliveries.
731 report.ReportMetadata.Errors = errors
733 // We'll fill this with the last-used record, not the one we fetch fresh from DSN.
734 // They will almost always be the same, but if not, the fresh record was never
735 // actually used for evaluations, so no point in reporting it.
736 report.PolicyPublished = last.PolicyPublished
738 // Process records in-order for testable results.
739 recstrs := maps.Keys(counts)
740 sort.Strings(recstrs)
741 for _, recstr := range recstrs {
743 rc.ReportRecord.Row.Count = rc.count
744 report.Records = append(report.Records, rc.ReportRecord)
747 reportFile, err := store.CreateMessageTemp("dmarcreportout")
749 return false, fmt.Errorf("creating temporary file for outgoing dmarc aggregate report: %v", err)
751 defer store.CloseRemoveTempFile(log, reportFile, "generated dmarc aggregate report")
753 gzw := gzip.NewWriter(reportFile)
754 _, err = fmt.Fprint(gzw, xml.Header)
755 enc := xml.NewEncoder(gzw)
756 enc.Indent("", "\t") // Keep up pretention that xml is human-readable.
758 err = enc.Encode(report)
767 return true, fmt.Errorf("writing dmarc aggregate report as xml with gzip: %v", err)
770 msgf, err := store.CreateMessageTemp("dmarcreportmsgout")
772 return false, fmt.Errorf("creating temporary message file with outgoing dmarc aggregate report: %v", err)
774 defer store.CloseRemoveTempFile(log, msgf, "message with generated dmarc aggregate report")
776 // We are sending reports from our host's postmaster address. In a
777 // typical setup the host is a subdomain of a configured domain with
778 // DKIM keys, so we can DKIM-sign our reports. SPF should pass anyway.
779 // A single report can contain deliveries from a single policy domain
780 // to multiple of our configured domains.
781 from := smtp.Address{Localpart: "postmaster", Domain: mox.Conf.Static.HostnameDomain}
784 subject := fmt.Sprintf("Report Domain: %s Submitter: %s Report-ID: <%s>", dom.ASCII, mox.Conf.Static.HostnameDomain.ASCII, report.ReportMetadata.ReportID)
787 text := fmt.Sprintf(`Attached is an aggregate DMARC report with results of evaluations of the DMARC
788policy of your domain for messages received by us that have your domain in the
789message From header. You are receiving this message because your address is
790specified in the "rua" field of the DMARC record for your domain.
796`, dom, mox.Conf.Static.HostnameDomain, report.ReportMetadata.ReportID, beginTime.UTC().Format(time.DateTime), endTime.UTC().Format(time.DateTime))
799 reportFilename := fmt.Sprintf("%s!%s!%d!%d.xml.gz", mox.Conf.Static.HostnameDomain.ASCII, dom.ASCII, beginTime.Unix(), endTime.Add(-time.Second).Unix())
801 var addrs []message.NameAddress
802 for _, rcpt := range recipients {
803 addrs = append(addrs, message.NameAddress{Address: rcpt.address})
806 // Compose the message.
807 msgPrefix, has8bit, smtputf8, messageID, err := composeAggregateReport(ctx, log, msgf, from, addrs, subject, text, reportFilename, reportFile)
809 return false, fmt.Errorf("composing message with outgoing dmarc aggregate report: %v", err)
812 // Get size of message after all compression and encodings (base64 makes it big
813 // again), and go through potentials recipients (rua). If they are willing to
814 // accept the report, queue it.
815 msgInfo, err := msgf.Stat()
817 return false, fmt.Errorf("stat message with outgoing dmarc aggregate report: %v", err)
819 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
821 for _, rcpt := range recipients {
822 // If recipient is on suppression list, we won't queue the reporting message.
823 q := bstore.QueryDB[SuppressAddress](ctx, db)
824 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.address.Path().String()})
825 q.FilterGreater("Until", time.Now())
826 exists, err := q.Exists()
828 return false, fmt.Errorf("querying suppress list: %v", err)
831 log.Info("suppressing outgoing dmarc aggregate report", mlog.Field("reportingaddress", rcpt.address))
835 // Only send to addresses where we don't exceed their size limit. The RFC mentions
836 // the size of the report, but then continues about the size after compression and
837 // transport encodings (i.e. gzip and the mime base64 attachment, so the intention
838 // is probably to compare against the size of the message that contains the report.
840 if rcpt.maxSize > 0 && msgSize > int64(rcpt.maxSize) {
844 qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, from.Path(), rcpt.address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil)
845 // Don't try as long as regular deliveries, and stop before we would send the
846 // delayed DSN. Though we also won't send that due to IsDMARCReport.
848 qm.IsDMARCReport = true
850 err = queueAdd(ctx, log, &qm, msgf)
853 log.Errorx("queueing message with dmarc aggregate report", err)
854 metricReportError.Inc()
856 log.Debug("dmarc aggregate report queued", mlog.Field("recipient", rcpt.address))
863 if err := sendErrorReport(ctx, log, db, from, addrs, dom, report.ReportMetadata.ReportID, msgSize); err != nil {
864 log.Errorx("sending dmarc error reports", err)
865 metricReportError.Inc()
869 // Regardless of whether we queued a report, we are not going to keep the
870 // evaluations around. Though this can be overridden if tempError is set.
876func 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) {
877 xc := message.NewComposer(mf, 100*1024*1024)
883 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
890 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
891 for _, a := range recipients {
892 if a.Address.Localpart.IsInternational() {
898 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
899 xc.HeaderAddrs("To", recipients)
901 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
902 xc.Header("Message-Id", messageID)
903 xc.Header("Date", time.Now().Format(message.RFC5322Z))
904 xc.Header("User-Agent", "mox/"+moxvar.Version)
905 xc.Header("MIME-Version", "1.0")
907 // Multipart message, with a text/plain and the report attached.
908 mp := multipart.NewWriter(xc)
909 xc.Header("Content-Type", fmt.Sprintf(`multipart/mixed; boundary="%s"`, mp.Boundary()))
912 // Textual part, just mentioning this is a DMARC report.
913 textBody, ct, cte := xc.TextPart(text)
914 textHdr := textproto.MIMEHeader{}
915 textHdr.Set("Content-Type", ct)
916 textHdr.Set("Content-Transfer-Encoding", cte)
917 textp, err := mp.CreatePart(textHdr)
918 xc.Checkf(err, "adding text part to message")
919 _, err = textp.Write(textBody)
920 xc.Checkf(err, "writing text part")
922 // DMARC report as attachment.
923 ahdr := textproto.MIMEHeader{}
924 ahdr.Set("Content-Type", "application/gzip")
925 ahdr.Set("Content-Transfer-Encoding", "base64")
926 cd := mime.FormatMediaType("attachment", map[string]string{"filename": filename})
927 ahdr.Set("Content-Disposition", cd)
928 ap, err := mp.CreatePart(ahdr)
929 xc.Checkf(err, "adding dmarc aggregate report to message")
930 wc := moxio.Base64Writer(ap)
931 _, err = io.Copy(wc, &moxio.AtReader{R: reportXMLGzipFile})
932 xc.Checkf(err, "adding attachment")
934 xc.Checkf(err, "flushing attachment")
937 xc.Checkf(err, "closing multipart")
941 msgPrefix = dkimSign(ctx, log, fromAddr, xc.SMTPUTF8, mf)
943 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
946// Though this functionality is quite underspecified, we'll do our best to send our
947// an error report in case our report is too large for all recipients.
949func sendErrorReport(ctx context.Context, log *mlog.Log, db *bstore.DB, fromAddr smtp.Address, recipients []message.NameAddress, reportDomain dns.Domain, reportID string, reportMsgSize int64) error {
950 log.Debug("no reporting addresses willing to accept report given size, queuing short error message")
952 msgf, err := store.CreateMessageTemp("dmarcreportmsg-out")
954 return fmt.Errorf("creating temporary message file for outgoing dmarc error report: %v", err)
956 defer store.CloseRemoveTempFile(log, msgf, "outgoing dmarc error report message")
958 var recipientStrs []string
959 for _, rcpt := range recipients {
960 recipientStrs = append(recipientStrs, rcpt.Address.String())
963 subject := fmt.Sprintf("DMARC aggregate reporting error report for %s", reportDomain.ASCII)
965 text := fmt.Sprintf(`Report-Date: %s
971`, time.Now().Format(message.RFC5322Z), reportDomain.ASCII, reportID, reportMsgSize, mox.Conf.Static.HostnameDomain.ASCII, strings.Join(recipientStrs, ","))
972 text = strings.ReplaceAll(text, "\n", "\r\n")
974 msgPrefix, has8bit, smtputf8, messageID, err := composeErrorReport(ctx, log, msgf, fromAddr, recipients, subject, text)
979 msgInfo, err := msgf.Stat()
981 return fmt.Errorf("stat message with outgoing dmarc error report: %v", err)
983 msgSize := int64(len(msgPrefix)) + msgInfo.Size()
985 for _, rcpt := range recipients {
986 // If recipient is on suppression list, we won't queue the reporting message.
987 q := bstore.QueryDB[SuppressAddress](ctx, db)
988 q.FilterNonzero(SuppressAddress{ReportingAddress: rcpt.Address.Path().String()})
989 q.FilterGreater("Until", time.Now())
990 exists, err := q.Exists()
992 return fmt.Errorf("querying suppress list: %v", err)
995 log.Info("suppressing outgoing dmarc error report", mlog.Field("reportingaddress", rcpt.Address))
999 qm := queue.MakeMsg(mox.Conf.Static.Postmaster.Account, fromAddr.Path(), rcpt.Address.Path(), has8bit, smtputf8, msgSize, messageID, []byte(msgPrefix), nil)
1000 // Don't try as long as regular deliveries, and stop before we would send the
1001 // delayed DSN. Though we also won't send that due to IsDMARCReport.
1003 qm.IsDMARCReport = true
1005 if err := queueAdd(ctx, log, &qm, msgf); err != nil {
1006 log.Errorx("queueing message with dmarc error report", err)
1007 metricReportError.Inc()
1009 log.Debug("dmarc error report queued", mlog.Field("recipient", rcpt))
1016func 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) {
1017 xc := message.NewComposer(mf, 100*1024*1024)
1023 if err, ok := x.(error); ok && errors.Is(err, message.ErrCompose) {
1030 // We only use smtputf8 if we have to, with a utf-8 localpart. For IDNA, we use ASCII domains.
1031 for _, a := range recipients {
1032 if a.Address.Localpart.IsInternational() {
1038 xc.HeaderAddrs("From", []message.NameAddress{{Address: fromAddr}})
1039 xc.HeaderAddrs("To", recipients)
1040 xc.Header("Subject", subject)
1041 messageID = fmt.Sprintf("<%s>", mox.MessageIDGen(xc.SMTPUTF8))
1042 xc.Header("Message-Id", messageID)
1043 xc.Header("Date", time.Now().Format(message.RFC5322Z))
1044 xc.Header("User-Agent", "mox/"+moxvar.Version)
1045 xc.Header("MIME-Version", "1.0")
1047 textBody, ct, cte := xc.TextPart(text)
1048 xc.Header("Content-Type", ct)
1049 xc.Header("Content-Transfer-Encoding", cte)
1051 _, err := xc.Write(textBody)
1052 xc.Checkf(err, "writing text")
1056 msgPrefix = dkimSign(ctx, log, fromAddr, smtputf8, mf)
1058 return msgPrefix, xc.Has8bit, xc.SMTPUTF8, messageID, nil
1061func dkimSign(ctx context.Context, log *mlog.Log, fromAddr smtp.Address, smtputf8 bool, mf *os.File) string {
1062 // Add DKIM-Signature headers if we have a key for (a higher) domain than the from
1063 // address, which is a host name. A signature will only be useful with higher-level
1064 // domains if they have a relaxed dkim check (which is the default). If the dkim
1065 // check is strict, there is no harm, there will simply not be a dkim pass.
1066 fd := fromAddr.Domain
1067 var zerodom dns.Domain
1069 confDom, ok := mox.Conf.Domain(fd)
1070 if len(confDom.DKIM.Sign) > 0 {
1071 dkimHeaders, err := dkim.Sign(ctx, fromAddr.Localpart, fd, confDom.DKIM, smtputf8, mf)
1073 log.Errorx("dkim-signing dmarc report, continuing without signature", err)
1074 metricReportError.Inc()
1083 _, nfd.ASCII, _ = strings.Cut(fd.ASCII, ".")
1084 _, nfd.Unicode, _ = strings.Cut(fd.Unicode, ".")
1090// SuppressAdd adds an address to the suppress list.
1091func SuppressAdd(ctx context.Context, ba *SuppressAddress) error {
1092 db, err := evalDB(ctx)
1097 return db.Insert(ctx, ba)
1100// SuppressList returns all reporting addresses on the suppress list.
1101func SuppressList(ctx context.Context) ([]SuppressAddress, error) {
1102 db, err := evalDB(ctx)
1107 return bstore.QueryDB[SuppressAddress](ctx, db).SortDesc("ID").List()
1110// SuppressRemove removes a reporting address record from the suppress list.
1111func SuppressRemove(ctx context.Context, id int64) error {
1112 db, err := evalDB(ctx)
1117 return db.Delete(ctx, &SuppressAddress{ID: id})
1120// SuppressUpdate updates the until field of a reporting address record.
1121func SuppressUpdate(ctx context.Context, id int64, until time.Time) error {
1122 db, err := evalDB(ctx)
1127 ba := SuppressAddress{ID: id}
1128 err = db.Get(ctx, &ba)
1133 return db.Update(ctx, &ba)