16 "github.com/mjl-/mox/dmarcrpt"
17 "github.com/mjl-/mox/dns"
18 "github.com/mjl-/mox/mlog"
19 "github.com/mjl-/mox/mox-"
20 "github.com/mjl-/mox/moxio"
21 "github.com/mjl-/mox/queue"
25func tcheckf(t *testing.T, err error, format string, args ...any) {
28 t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
32func tcompare(t *testing.T, got, expect any) {
34 if !reflect.DeepEqual(got, expect) {
35 t.Fatalf("got:\n%v\nexpected:\n%v", got, expect)
39func TestEvaluations(t *testing.T) {
40 os.RemoveAll("../testdata/dmarcdb/data")
42 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
43 mox.MustLoadConfig(true, false)
45 os.Remove(mox.DataDirPath("dmarceval.db"))
47 tcheckf(t, err, "init")
50 tcheckf(t, err, "close")
53 parseJSON := func(s string) (e Evaluation) {
55 err := json.Unmarshal([]byte(s), &e)
56 tcheckf(t, err, "unmarshal")
59 packJSON := func(e Evaluation) string {
61 buf, err := json.Marshal(e)
62 tcheckf(t, err, "marshal")
67 PolicyDomain: "sender1.example",
68 Evaluated: time.Now().Round(0),
70 PolicyPublished: dmarcrpt.PolicyPublished{
71 Domain: "sender1.example",
72 ADKIM: dmarcrpt.AlignmentRelaxed,
73 ASPF: dmarcrpt.AlignmentRelaxed,
74 Policy: dmarcrpt.DispositionReject,
75 SubdomainPolicy: dmarcrpt.DispositionReject,
79 Disposition: dmarcrpt.DispositionNone,
80 AlignedDKIMPass: true,
82 EnvelopeTo: "mox.example",
83 EnvelopeFrom: "sender1.example",
84 HeaderFrom: "sender1.example",
85 DKIMResults: []dmarcrpt.DKIMAuthResult{
87 Domain: "sender1.example",
89 Result: dmarcrpt.DKIMPass,
92 SPFResults: []dmarcrpt.SPFAuthResult{
94 Domain: "sender1.example",
95 Scope: dmarcrpt.SPFDomainScopeMailFrom,
96 Result: dmarcrpt.SPFPass,
101 e2 := parseJSON(strings.ReplaceAll(packJSON(e0), "sender1.example", "sender2.example"))
102 e3 := parseJSON(strings.ReplaceAll(packJSON(e0), "10.1.2.3", "10.3.2.1"))
105 for i, e := range []*Evaluation{&e0, &e1, &e2, &e3} {
106 e.Evaluated = e.Evaluated.Add(time.Duration(i) * time.Second)
107 err = AddEvaluation(ctxbg, 3600, e)
108 tcheckf(t, err, "add evaluation")
111 expStats := map[string]EvaluationStat{
113 Domain: dns.Domain{ASCII: "sender1.example"},
114 Dispositions: []string{"none"},
119 Domain: dns.Domain{ASCII: "sender2.example"},
120 Dispositions: []string{"none"},
125 stats, err := EvaluationStats(ctxbg)
126 tcheckf(t, err, "evaluation stats")
127 tcompare(t, stats, expStats)
130 evals, err := EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
131 tcheckf(t, err, "get evaluations for domain")
132 tcompare(t, evals, []Evaluation{e0, e1, e3})
134 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender2.example"})
135 tcheckf(t, err, "get evaluations for domain")
136 tcompare(t, evals, []Evaluation{e2})
138 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "bogus.example"})
139 tcheckf(t, err, "get evaluations for domain")
140 tcompare(t, evals, []Evaluation{})
142 // RemoveEvaluationsDomain
143 err = RemoveEvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
144 tcheckf(t, err, "remove evaluations")
146 expStats = map[string]EvaluationStat{
148 Domain: dns.Domain{ASCII: "sender2.example"},
149 Dispositions: []string{"none"},
154 stats, err = EvaluationStats(ctxbg)
155 tcheckf(t, err, "evaluation stats")
156 tcompare(t, stats, expStats)
159func TestSendReports(t *testing.T) {
160 os.RemoveAll("../testdata/dmarcdb/data")
162 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
163 mox.MustLoadConfig(true, false)
165 os.Remove(mox.DataDirPath("dmarceval.db"))
167 tcheckf(t, err, "init")
170 tcheckf(t, err, "close")
173 resolver := dns.MockResolver{
174 TXT: map[string][]string{
175 "_dmarc.sender.example.": {
176 "v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
181 end := nextWholeHour(time.Now())
184 PolicyDomain: "sender.example",
185 Evaluated: end.Add(-time.Hour / 2),
187 PolicyPublished: dmarcrpt.PolicyPublished{
188 Domain: "sender.example",
189 ADKIM: dmarcrpt.AlignmentRelaxed,
190 ASPF: dmarcrpt.AlignmentRelaxed,
191 Policy: dmarcrpt.DispositionReject,
192 SubdomainPolicy: dmarcrpt.DispositionReject,
195 SourceIP: "10.1.2.3",
196 Disposition: dmarcrpt.DispositionNone,
197 AlignedDKIMPass: true,
198 AlignedSPFPass: true,
199 EnvelopeTo: "mox.example",
200 EnvelopeFrom: "sender.example",
201 HeaderFrom: "sender.example",
202 DKIMResults: []dmarcrpt.DKIMAuthResult{
204 Domain: "sender.example",
206 Result: dmarcrpt.DKIMPass,
209 SPFResults: []dmarcrpt.SPFAuthResult{
211 Domain: "sender.example",
212 Scope: dmarcrpt.SPFDomainScopeMailFrom,
213 Result: dmarcrpt.SPFPass,
218 expFeedback := &dmarcrpt.Feedback{
219 XMLName: xml.Name{Local: "feedback"},
221 ReportMetadata: dmarcrpt.ReportMetadata{
222 OrgName: "mail.mox.example",
223 Email: "postmaster@mail.mox.example",
224 DateRange: dmarcrpt.DateRange{
225 Begin: end.Add(-1 * time.Hour).Unix(),
226 End: end.Add(-time.Second).Unix(),
229 PolicyPublished: dmarcrpt.PolicyPublished{
230 Domain: "sender.example",
231 ADKIM: dmarcrpt.AlignmentRelaxed,
232 ASPF: dmarcrpt.AlignmentRelaxed,
233 Policy: dmarcrpt.DispositionReject,
234 SubdomainPolicy: dmarcrpt.DispositionReject,
237 Records: []dmarcrpt.ReportRecord{
240 SourceIP: "10.1.2.3",
242 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
243 Disposition: dmarcrpt.DispositionNone,
244 DKIM: dmarcrpt.DMARCPass,
245 SPF: dmarcrpt.DMARCPass,
248 Identifiers: dmarcrpt.Identifiers{
249 EnvelopeTo: "mox.example",
250 EnvelopeFrom: "sender.example",
251 HeaderFrom: "sender.example",
253 AuthResults: dmarcrpt.AuthResults{
254 DKIM: []dmarcrpt.DKIMAuthResult{
256 Domain: "sender.example",
258 Result: dmarcrpt.DKIMPass,
261 SPF: []dmarcrpt.SPFAuthResult{
263 Domain: "sender.example",
264 Scope: dmarcrpt.SPFDomainScopeMailFrom,
265 Result: dmarcrpt.SPFPass,
273 // Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
274 wait := make(chan struct{})
275 step := make(chan time.Duration)
276 jitteredTimeUntil = func(_ time.Time) time.Duration {
281 sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
283 test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
286 mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
288 for _, e := range evals {
289 err := EvalDB.Insert(ctxbg, &e)
290 tcheckf(t, err, "inserting evaluation")
293 aggrAddrs := map[string]struct{}{}
294 errorAddrs := map[string]struct{}{}
296 queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
298 return fmt.Errorf("queued %d messages, expected 1", len(qml))
302 // Read message file. Also write copy to disk for inspection.
303 buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
304 tcheckf(t, err, "read report message")
305 err = os.WriteFile("../testdata/dmarcdb/data/report.eml", slices.Concat(qm.MsgPrefix, buf), 0600)
306 tcheckf(t, err, "write report message")
308 var feedback *dmarcrpt.Feedback
309 addr := qm.Recipient().String()
310 isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
312 errorAddrs[addr] = struct{}{}
314 aggrAddrs[addr] = struct{}{}
316 feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
317 tcheckf(t, err, "parsing generated report message")
320 if optExpReport != nil {
321 // Parse report in message and compare with expected.
322 optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
323 tcompare(t, feedback, expFeedback)
334 tcompare(t, aggrAddrs, expAggrAddrs)
335 tcompare(t, errorAddrs, expErrorAddrs)
337 // Second loop. Evaluations cleaned, should not result in report messages.
338 aggrAddrs = map[string]struct{}{}
339 errorAddrs = map[string]struct{}{}
342 tcompare(t, aggrAddrs, map[string]struct{}{})
343 tcompare(t, errorAddrs, map[string]struct{}{})
345 // Caus Start to stop.
350 // Typical case, with a single address that receives an aggregate report.
351 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
353 // Only optional evaluations, no report at all.
355 evalOpt.Optional = true
356 test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
358 // Address is suppressed.
359 sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
360 err = EvalDB.Insert(ctxbg, &sa)
361 tcheckf(t, err, "insert suppress address")
362 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
364 // Suppression has expired.
365 sa.Until = time.Now().Add(-time.Minute)
366 err = EvalDB.Update(ctxbg, &sa)
367 tcheckf(t, err, "update suppress address")
368 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
370 // Two RUA's, one with a size limit that doesn't pass, and one that does pass.
371 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
372 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
374 // Redirect to external domain, without permission, no report sent.
375 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
376 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
378 // Redirect to external domain, with basic permission.
379 resolver.TXT = map[string][]string{
380 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
381 "sender.example._report._dmarc.other.example.": {"v=DMARC1"},
383 test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
385 // Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
386 resolver.TXT = map[string][]string{
387 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
388 "sender.example._report._dmarc.other.example.": {"v=DMARC1; rua=mailto:good1@other.example,mailto:bad1@yetanother.example,mailto:good2@other.example,mailto:badsize@other.example!1"},
390 test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
392 // Without RUA, we send no message.
393 resolver.TXT = map[string][]string{
394 "_dmarc.sender.example.": {"v=DMARC1;"},
396 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
398 // If message size limit is reached, an error repor is sent.
399 resolver.TXT = map[string][]string{
400 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
402 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)