17 "github.com/mjl-/mox/dmarcrpt"
18 "github.com/mjl-/mox/dns"
19 "github.com/mjl-/mox/mlog"
20 "github.com/mjl-/mox/mox-"
21 "github.com/mjl-/mox/moxio"
22 "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)
46 _, err := evalDB(ctxbg)
47 tcheckf(t, err, "database")
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 mlog.SetConfig(map[string]slog.Level{"": slog.LevelDebug})
162 os.RemoveAll("../testdata/dmarcdb/data")
164 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
165 mox.MustLoadConfig(true, false)
168 db, err := evalDB(ctxbg)
169 tcheckf(t, err, "database")
175 resolver := dns.MockResolver{
176 TXT: map[string][]string{
177 "_dmarc.sender.example.": {
178 "v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
183 end := nextWholeHour(time.Now())
186 PolicyDomain: "sender.example",
187 Evaluated: end.Add(-time.Hour / 2),
189 PolicyPublished: dmarcrpt.PolicyPublished{
190 Domain: "sender.example",
191 ADKIM: dmarcrpt.AlignmentRelaxed,
192 ASPF: dmarcrpt.AlignmentRelaxed,
193 Policy: dmarcrpt.DispositionReject,
194 SubdomainPolicy: dmarcrpt.DispositionReject,
197 SourceIP: "10.1.2.3",
198 Disposition: dmarcrpt.DispositionNone,
199 AlignedDKIMPass: true,
200 AlignedSPFPass: true,
201 EnvelopeTo: "mox.example",
202 EnvelopeFrom: "sender.example",
203 HeaderFrom: "sender.example",
204 DKIMResults: []dmarcrpt.DKIMAuthResult{
206 Domain: "sender.example",
208 Result: dmarcrpt.DKIMPass,
211 SPFResults: []dmarcrpt.SPFAuthResult{
213 Domain: "sender.example",
214 Scope: dmarcrpt.SPFDomainScopeMailFrom,
215 Result: dmarcrpt.SPFPass,
220 expFeedback := &dmarcrpt.Feedback{
221 XMLName: xml.Name{Local: "feedback"},
223 ReportMetadata: dmarcrpt.ReportMetadata{
224 OrgName: "mail.mox.example",
225 Email: "postmaster@mail.mox.example",
226 DateRange: dmarcrpt.DateRange{
227 Begin: end.Add(-1 * time.Hour).Unix(),
228 End: end.Add(-time.Second).Unix(),
231 PolicyPublished: dmarcrpt.PolicyPublished{
232 Domain: "sender.example",
233 ADKIM: dmarcrpt.AlignmentRelaxed,
234 ASPF: dmarcrpt.AlignmentRelaxed,
235 Policy: dmarcrpt.DispositionReject,
236 SubdomainPolicy: dmarcrpt.DispositionReject,
239 Records: []dmarcrpt.ReportRecord{
242 SourceIP: "10.1.2.3",
244 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
245 Disposition: dmarcrpt.DispositionNone,
246 DKIM: dmarcrpt.DMARCPass,
247 SPF: dmarcrpt.DMARCPass,
250 Identifiers: dmarcrpt.Identifiers{
251 EnvelopeTo: "mox.example",
252 EnvelopeFrom: "sender.example",
253 HeaderFrom: "sender.example",
255 AuthResults: dmarcrpt.AuthResults{
256 DKIM: []dmarcrpt.DKIMAuthResult{
258 Domain: "sender.example",
260 Result: dmarcrpt.DKIMPass,
263 SPF: []dmarcrpt.SPFAuthResult{
265 Domain: "sender.example",
266 Scope: dmarcrpt.SPFDomainScopeMailFrom,
267 Result: dmarcrpt.SPFPass,
275 // Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
276 wait := make(chan struct{})
277 step := make(chan time.Duration)
278 jitteredTimeUntil = func(_ time.Time) time.Duration {
283 sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
285 test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
288 mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
290 for _, e := range evals {
291 err := db.Insert(ctxbg, &e)
292 tcheckf(t, err, "inserting evaluation")
295 aggrAddrs := map[string]struct{}{}
296 errorAddrs := map[string]struct{}{}
298 queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
300 return fmt.Errorf("queued %d messages, expected 1", len(qml))
304 // Read message file. Also write copy to disk for inspection.
305 buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
306 tcheckf(t, err, "read report message")
307 err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
308 tcheckf(t, err, "write report message")
310 var feedback *dmarcrpt.Feedback
311 addr := qm.Recipient().String()
312 isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
314 errorAddrs[addr] = struct{}{}
316 aggrAddrs[addr] = struct{}{}
318 feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
319 tcheckf(t, err, "parsing generated report message")
322 if optExpReport != nil {
323 // Parse report in message and compare with expected.
324 optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
325 tcompare(t, feedback, expFeedback)
336 tcompare(t, aggrAddrs, expAggrAddrs)
337 tcompare(t, errorAddrs, expErrorAddrs)
339 // Second loop. Evaluations cleaned, should not result in report messages.
340 aggrAddrs = map[string]struct{}{}
341 errorAddrs = map[string]struct{}{}
344 tcompare(t, aggrAddrs, map[string]struct{}{})
345 tcompare(t, errorAddrs, map[string]struct{}{})
347 // Caus Start to stop.
352 // Typical case, with a single address that receives an aggregate report.
353 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
355 // Only optional evaluations, no report at all.
357 evalOpt.Optional = true
358 test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
360 // Address is suppressed.
361 sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
362 err = db.Insert(ctxbg, &sa)
363 tcheckf(t, err, "insert suppress address")
364 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
366 // Suppression has expired.
367 sa.Until = time.Now().Add(-time.Minute)
368 err = db.Update(ctxbg, &sa)
369 tcheckf(t, err, "update suppress address")
370 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
372 // Two RUA's, one with a size limit that doesn't pass, and one that does pass.
373 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
374 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
376 // Redirect to external domain, without permission, no report sent.
377 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
378 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
380 // Redirect to external domain, with basic permission.
381 resolver.TXT = map[string][]string{
382 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
383 "sender.example._report._dmarc.other.example.": {"v=DMARC1"},
385 test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
387 // Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
388 resolver.TXT = map[string][]string{
389 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
390 "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"},
392 test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
394 // Without RUA, we send no message.
395 resolver.TXT = map[string][]string{
396 "_dmarc.sender.example.": {"v=DMARC1;"},
398 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
400 // If message size limit is reached, an error repor is sent.
401 resolver.TXT = map[string][]string{
402 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
404 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)