1package dmarcdb
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/prometheus/client_golang/prometheus"
9 "github.com/prometheus/client_golang/prometheus/promauto"
10
11 "github.com/mjl-/bstore"
12
13 "github.com/mjl-/mox/dmarcrpt"
14 "github.com/mjl-/mox/dns"
15)
16
17var (
18 ReportsDBTypes = []any{DomainFeedback{}} // Types stored in DB.
19 ReportsDB *bstore.DB // Exported for backups.
20)
21
22var (
23 metricEvaluated = promauto.NewCounterVec(
24 prometheus.CounterOpts{
25 Name: "mox_dmarcdb_policy_evaluated_total",
26 Help: "Number of policy evaluations.",
27 },
28 // We only register validated domains for which we have a config.
29 []string{"domain", "disposition", "dkim", "spf"},
30 )
31 metricDKIM = promauto.NewCounterVec(
32 prometheus.CounterOpts{
33 Name: "mox_dmarcdb_dkim_result_total",
34 Help: "Number of DKIM results.",
35 },
36 []string{"result"},
37 )
38 metricSPF = promauto.NewCounterVec(
39 prometheus.CounterOpts{
40 Name: "mox_dmarcdb_spf_result_total",
41 Help: "Number of SPF results.",
42 },
43 []string{"result"},
44 )
45)
46
47// DomainFeedback is a single report stored in the database.
48type DomainFeedback struct {
49 ID int64
50 // Domain where DMARC DNS record was found, could be organizational domain.
51 Domain string `bstore:"index"`
52 // Domain in From-header.
53 FromDomain string `bstore:"index"`
54 dmarcrpt.Feedback
55}
56
57// AddReport adds a DMARC aggregate feedback report from an email to the database,
58// and updates prometheus metrics.
59//
60// fromDomain is the domain in the report message From header.
61func AddReport(ctx context.Context, f *dmarcrpt.Feedback, fromDomain dns.Domain) error {
62 d, err := dns.ParseDomain(f.PolicyPublished.Domain)
63 if err != nil {
64 return fmt.Errorf("parsing domain in report: %v", err)
65 }
66
67 df := DomainFeedback{0, d.Name(), fromDomain.Name(), *f}
68 if err := ReportsDB.Insert(ctx, &df); err != nil {
69 return err
70 }
71
72 for _, r := range f.Records {
73 for _, dkim := range r.AuthResults.DKIM {
74 count := r.Row.Count
75 if count > 0 {
76 metricDKIM.With(prometheus.Labels{
77 "result": string(dkim.Result),
78 }).Add(float64(count))
79 }
80 }
81
82 for _, spf := range r.AuthResults.SPF {
83 count := r.Row.Count
84 if count > 0 {
85 metricSPF.With(prometheus.Labels{
86 "result": string(spf.Result),
87 }).Add(float64(count))
88 }
89 }
90
91 count := r.Row.Count
92 if count > 0 {
93 pe := r.Row.PolicyEvaluated
94 metricEvaluated.With(prometheus.Labels{
95 "domain": f.PolicyPublished.Domain,
96 "disposition": string(pe.Disposition),
97 "dkim": string(pe.DKIM),
98 "spf": string(pe.SPF),
99 }).Add(float64(count))
100 }
101 }
102 return nil
103}
104
105// Records returns all reports in the database.
106func Records(ctx context.Context) ([]DomainFeedback, error) {
107 return bstore.QueryDB[DomainFeedback](ctx, ReportsDB).List()
108}
109
110// RecordID returns the report for the ID.
111func RecordID(ctx context.Context, id int64) (DomainFeedback, error) {
112 e := DomainFeedback{ID: id}
113 err := ReportsDB.Get(ctx, &e)
114 return e, err
115}
116
117// RecordsPeriodDomain returns the reports overlapping start and end, for the given
118// domain. If domain is empty, all records match for domain.
119func RecordsPeriodDomain(ctx context.Context, start, end time.Time, domain string) ([]DomainFeedback, error) {
120 s := start.Unix()
121 e := end.Unix()
122
123 q := bstore.QueryDB[DomainFeedback](ctx, ReportsDB)
124 if domain != "" {
125 q.FilterNonzero(DomainFeedback{Domain: domain})
126 }
127 q.FilterFn(func(d DomainFeedback) bool {
128 m := d.Feedback.ReportMetadata.DateRange
129 return m.Begin >= s && m.Begin < e || m.End > s && m.End <= e
130 })
131 return q.List()
132}
133