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"
24func tcheckf(t *testing.T, err error, format string, args ...any) {
27 t.Fatalf("%s: %s", fmt.Sprintf(format, args...), err)
31func tcompare(t *testing.T, got, expect any) {
33 if !reflect.DeepEqual(got, expect) {
34 t.Fatalf("got:\n%v\nexpected:\n%v", got, expect)
38func TestEvaluations(t *testing.T) {
39 os.RemoveAll("../testdata/dmarcdb/data")
41 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
42 mox.MustLoadConfig(true, false)
44 os.Remove(mox.DataDirPath("dmarceval.db"))
46 tcheckf(t, err, "init")
49 tcheckf(t, err, "close")
52 parseJSON := func(s string) (e Evaluation) {
54 err := json.Unmarshal([]byte(s), &e)
55 tcheckf(t, err, "unmarshal")
58 packJSON := func(e Evaluation) string {
60 buf, err := json.Marshal(e)
61 tcheckf(t, err, "marshal")
66 PolicyDomain: "sender1.example",
67 Evaluated: time.Now().Round(0),
69 PolicyPublished: dmarcrpt.PolicyPublished{
70 Domain: "sender1.example",
71 ADKIM: dmarcrpt.AlignmentRelaxed,
72 ASPF: dmarcrpt.AlignmentRelaxed,
73 Policy: dmarcrpt.DispositionReject,
74 SubdomainPolicy: dmarcrpt.DispositionReject,
78 Disposition: dmarcrpt.DispositionNone,
79 AlignedDKIMPass: true,
81 EnvelopeTo: "mox.example",
82 EnvelopeFrom: "sender1.example",
83 HeaderFrom: "sender1.example",
84 DKIMResults: []dmarcrpt.DKIMAuthResult{
86 Domain: "sender1.example",
88 Result: dmarcrpt.DKIMPass,
91 SPFResults: []dmarcrpt.SPFAuthResult{
93 Domain: "sender1.example",
94 Scope: dmarcrpt.SPFDomainScopeMailFrom,
95 Result: dmarcrpt.SPFPass,
100 e2 := parseJSON(strings.ReplaceAll(packJSON(e0), "sender1.example", "sender2.example"))
101 e3 := parseJSON(strings.ReplaceAll(packJSON(e0), "10.1.2.3", "10.3.2.1"))
104 for i, e := range []*Evaluation{&e0, &e1, &e2, &e3} {
105 e.Evaluated = e.Evaluated.Add(time.Duration(i) * time.Second)
106 err = AddEvaluation(ctxbg, 3600, e)
107 tcheckf(t, err, "add evaluation")
110 expStats := map[string]EvaluationStat{
112 Domain: dns.Domain{ASCII: "sender1.example"},
113 Dispositions: []string{"none"},
118 Domain: dns.Domain{ASCII: "sender2.example"},
119 Dispositions: []string{"none"},
124 stats, err := EvaluationStats(ctxbg)
125 tcheckf(t, err, "evaluation stats")
126 tcompare(t, stats, expStats)
129 evals, err := EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
130 tcheckf(t, err, "get evaluations for domain")
131 tcompare(t, evals, []Evaluation{e0, e1, e3})
133 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender2.example"})
134 tcheckf(t, err, "get evaluations for domain")
135 tcompare(t, evals, []Evaluation{e2})
137 evals, err = EvaluationsDomain(ctxbg, dns.Domain{ASCII: "bogus.example"})
138 tcheckf(t, err, "get evaluations for domain")
139 tcompare(t, evals, []Evaluation{})
141 // RemoveEvaluationsDomain
142 err = RemoveEvaluationsDomain(ctxbg, dns.Domain{ASCII: "sender1.example"})
143 tcheckf(t, err, "remove evaluations")
145 expStats = map[string]EvaluationStat{
147 Domain: dns.Domain{ASCII: "sender2.example"},
148 Dispositions: []string{"none"},
153 stats, err = EvaluationStats(ctxbg)
154 tcheckf(t, err, "evaluation stats")
155 tcompare(t, stats, expStats)
158func TestSendReports(t *testing.T) {
159 os.RemoveAll("../testdata/dmarcdb/data")
161 mox.ConfigStaticPath = filepath.FromSlash("../testdata/dmarcdb/mox.conf")
162 mox.MustLoadConfig(true, false)
164 os.Remove(mox.DataDirPath("dmarceval.db"))
166 tcheckf(t, err, "init")
169 tcheckf(t, err, "close")
172 resolver := dns.MockResolver{
173 TXT: map[string][]string{
174 "_dmarc.sender.example.": {
175 "v=DMARC1; rua=mailto:dmarcrpt@sender.example; ri=3600",
180 end := nextWholeHour(time.Now())
183 PolicyDomain: "sender.example",
184 Evaluated: end.Add(-time.Hour / 2),
186 PolicyPublished: dmarcrpt.PolicyPublished{
187 Domain: "sender.example",
188 ADKIM: dmarcrpt.AlignmentRelaxed,
189 ASPF: dmarcrpt.AlignmentRelaxed,
190 Policy: dmarcrpt.DispositionReject,
191 SubdomainPolicy: dmarcrpt.DispositionReject,
194 SourceIP: "10.1.2.3",
195 Disposition: dmarcrpt.DispositionNone,
196 AlignedDKIMPass: true,
197 AlignedSPFPass: true,
198 EnvelopeTo: "mox.example",
199 EnvelopeFrom: "sender.example",
200 HeaderFrom: "sender.example",
201 DKIMResults: []dmarcrpt.DKIMAuthResult{
203 Domain: "sender.example",
205 Result: dmarcrpt.DKIMPass,
208 SPFResults: []dmarcrpt.SPFAuthResult{
210 Domain: "sender.example",
211 Scope: dmarcrpt.SPFDomainScopeMailFrom,
212 Result: dmarcrpt.SPFPass,
217 expFeedback := &dmarcrpt.Feedback{
218 XMLName: xml.Name{Local: "feedback"},
220 ReportMetadata: dmarcrpt.ReportMetadata{
221 OrgName: "mail.mox.example",
222 Email: "postmaster@mail.mox.example",
223 DateRange: dmarcrpt.DateRange{
224 Begin: end.Add(-1 * time.Hour).Unix(),
225 End: end.Add(-time.Second).Unix(),
228 PolicyPublished: dmarcrpt.PolicyPublished{
229 Domain: "sender.example",
230 ADKIM: dmarcrpt.AlignmentRelaxed,
231 ASPF: dmarcrpt.AlignmentRelaxed,
232 Policy: dmarcrpt.DispositionReject,
233 SubdomainPolicy: dmarcrpt.DispositionReject,
236 Records: []dmarcrpt.ReportRecord{
239 SourceIP: "10.1.2.3",
241 PolicyEvaluated: dmarcrpt.PolicyEvaluated{
242 Disposition: dmarcrpt.DispositionNone,
243 DKIM: dmarcrpt.DMARCPass,
244 SPF: dmarcrpt.DMARCPass,
247 Identifiers: dmarcrpt.Identifiers{
248 EnvelopeTo: "mox.example",
249 EnvelopeFrom: "sender.example",
250 HeaderFrom: "sender.example",
252 AuthResults: dmarcrpt.AuthResults{
253 DKIM: []dmarcrpt.DKIMAuthResult{
255 Domain: "sender.example",
257 Result: dmarcrpt.DKIMPass,
260 SPF: []dmarcrpt.SPFAuthResult{
262 Domain: "sender.example",
263 Scope: dmarcrpt.SPFDomainScopeMailFrom,
264 Result: dmarcrpt.SPFPass,
272 // Set a timeUntil that we steplock and that causes the actual sleep to return immediately when we want to.
273 wait := make(chan struct{})
274 step := make(chan time.Duration)
275 jitteredTimeUntil = func(_ time.Time) time.Duration {
280 sleepBetween = func(ctx context.Context, between time.Duration) (ok bool) { return true }
282 test := func(evals []Evaluation, expAggrAddrs map[string]struct{}, expErrorAddrs map[string]struct{}, optExpReport *dmarcrpt.Feedback) {
285 mox.Shutdown, mox.ShutdownCancel = context.WithCancel(ctxbg)
287 for _, e := range evals {
288 err := EvalDB.Insert(ctxbg, &e)
289 tcheckf(t, err, "inserting evaluation")
292 aggrAddrs := map[string]struct{}{}
293 errorAddrs := map[string]struct{}{}
295 queueAdd = func(ctx context.Context, log mlog.Log, senderAccount string, msgFile *os.File, qml ...queue.Msg) error {
297 return fmt.Errorf("queued %d messages, expected 1", len(qml))
301 // Read message file. Also write copy to disk for inspection.
302 buf, err := io.ReadAll(&moxio.AtReader{R: msgFile})
303 tcheckf(t, err, "read report message")
304 err = os.WriteFile("../testdata/dmarcdb/data/report.eml", append(append([]byte{}, qm.MsgPrefix...), buf...), 0600)
305 tcheckf(t, err, "write report message")
307 var feedback *dmarcrpt.Feedback
308 addr := qm.Recipient().String()
309 isErrorReport := strings.Contains(string(buf), "DMARC aggregate reporting error report")
311 errorAddrs[addr] = struct{}{}
313 aggrAddrs[addr] = struct{}{}
315 feedback, err = dmarcrpt.ParseMessageReport(log.Logger, msgFile)
316 tcheckf(t, err, "parsing generated report message")
319 if optExpReport != nil {
320 // Parse report in message and compare with expected.
321 optExpReport.ReportMetadata.ReportID = feedback.ReportMetadata.ReportID
322 tcompare(t, feedback, expFeedback)
333 tcompare(t, aggrAddrs, expAggrAddrs)
334 tcompare(t, errorAddrs, expErrorAddrs)
336 // Second loop. Evaluations cleaned, should not result in report messages.
337 aggrAddrs = map[string]struct{}{}
338 errorAddrs = map[string]struct{}{}
341 tcompare(t, aggrAddrs, map[string]struct{}{})
342 tcompare(t, errorAddrs, map[string]struct{}{})
344 // Caus Start to stop.
349 // Typical case, with a single address that receives an aggregate report.
350 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
352 // Only optional evaluations, no report at all.
354 evalOpt.Optional = true
355 test([]Evaluation{evalOpt}, map[string]struct{}{}, map[string]struct{}{}, nil)
357 // Address is suppressed.
358 sa := SuppressAddress{ReportingAddress: "dmarcrpt@sender.example", Until: time.Now().Add(time.Minute)}
359 err = EvalDB.Insert(ctxbg, &sa)
360 tcheckf(t, err, "insert suppress address")
361 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
363 // Suppression has expired.
364 sa.Until = time.Now().Add(-time.Minute)
365 err = EvalDB.Update(ctxbg, &sa)
366 tcheckf(t, err, "update suppress address")
367 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt@sender.example": {}}, map[string]struct{}{}, expFeedback)
369 // Two RUA's, one with a size limit that doesn't pass, and one that does pass.
370 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:dmarcrpt1@sender.example!1,mailto:dmarcrpt2@sender.example!10t; ri=3600"}
371 test([]Evaluation{eval}, map[string]struct{}{"dmarcrpt2@sender.example": {}}, map[string]struct{}{}, nil)
373 // Redirect to external domain, without permission, no report sent.
374 resolver.TXT["_dmarc.sender.example."] = []string{"v=DMARC1; rua=mailto:unauthorized@other.example"}
375 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
377 // Redirect to external domain, with basic permission.
378 resolver.TXT = map[string][]string{
379 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
380 "sender.example._report._dmarc.other.example.": {"v=DMARC1"},
382 test([]Evaluation{eval}, map[string]struct{}{"authorized@other.example": {}}, map[string]struct{}{}, nil)
384 // Redirect to authorized external domain, with 2 allowed replacements and 1 invalid and 1 refusing due to size.
385 resolver.TXT = map[string][]string{
386 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:authorized@other.example"},
387 "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"},
389 test([]Evaluation{eval}, map[string]struct{}{"good1@other.example": {}, "good2@other.example": {}}, map[string]struct{}{}, nil)
391 // Without RUA, we send no message.
392 resolver.TXT = map[string][]string{
393 "_dmarc.sender.example.": {"v=DMARC1;"},
395 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{}, nil)
397 // If message size limit is reached, an error repor is sent.
398 resolver.TXT = map[string][]string{
399 "_dmarc.sender.example.": {"v=DMARC1; rua=mailto:dmarcrpt@sender.example!1"},
401 test([]Evaluation{eval}, map[string]struct{}{}, map[string]struct{}{"dmarcrpt@sender.example": {}}, nil)