Skip to main content

mail_auth/dmarc/
verify.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{Alignment, Dmarc, Policy, Psd};
8use crate::DnsError;
9use crate::{
10    AuthenticatedMessage, Dkim2Result, DkimOutput, DkimResult, DmarcOutput, DmarcResult, Error, MX,
11    MessageAuthenticator, Parameters, RecordSet, ResolverCache, SpfOutput, SpfResult, Txt,
12    common::cache::NoCache, dkim2::Dkim2Output,
13};
14use std::{
15    net::{IpAddr, Ipv4Addr, Ipv6Addr},
16    sync::Arc,
17};
18
19pub struct DmarcParameters<'x> {
20    pub message: &'x AuthenticatedMessage<'x>,
21    pub dkim_output: &'x [DkimOutput<'x>],
22    pub dkim2_output: Option<&'x Dkim2Output<'x>>,
23    pub rfc5321_mail_from_domain: &'x str,
24    pub spf_output: &'x SpfOutput,
25}
26
27impl MessageAuthenticator {
28    /// Verifies the DMARC policy of an RFC5321.MailFrom domain
29    pub async fn verify_dmarc<'x, TXT, MXX, IPV4, IPV6, PTR>(
30        &self,
31        params: impl Into<Parameters<'x, DmarcParameters<'x>, TXT, MXX, IPV4, IPV6, PTR>>,
32    ) -> DmarcOutput
33    where
34        TXT: ResolverCache<Box<str>, Txt> + 'x,
35        MXX: ResolverCache<Box<str>, RecordSet<MX>> + 'x,
36        IPV4: ResolverCache<Box<str>, RecordSet<Ipv4Addr>> + 'x,
37        IPV6: ResolverCache<Box<str>, RecordSet<Ipv6Addr>> + 'x,
38        PTR: ResolverCache<IpAddr, RecordSet<Box<str>>> + 'x,
39    {
40        // Extract RFC5322.From domain
41        let params = params.into();
42        let message = params.params.message;
43        let dkim_output = params.params.dkim_output;
44        let dkim2_output = params.params.dkim2_output;
45        let rfc5321_mail_from_domain = params.params.rfc5321_mail_from_domain;
46        let spf_output = params.params.spf_output;
47        let cache_txt = params.cache_txt;
48        let cache_ipv4 = params.cache_ipv4;
49        let mut rfc5322_from_domain = "";
50        for from in &message.from {
51            if let Some((_, domain)) = from.rsplit_once('@') {
52                if rfc5322_from_domain.is_empty() {
53                    rfc5322_from_domain = domain;
54                } else if rfc5322_from_domain != domain {
55                    // Multi-valued RFC5322.From header fields with multiple
56                    // domains MUST be exempt from DMARC checking.
57                    return DmarcOutput::default();
58                }
59            }
60        }
61        if rfc5322_from_domain.is_empty() {
62            return DmarcOutput::default();
63        }
64
65        // Perform a DNS Tree Walk to discover the DMARC Policy Record for the
66        // Author Domain (RFC 9989 Section 4.10.1)
67        let walk = match self.dmarc_tree_walk(rfc5322_from_domain, cache_txt).await {
68            Ok(walk) => walk,
69            Err(err) => {
70                let err = DmarcResult::from(err);
71                return DmarcOutput::default()
72                    .with_domain(rfc5322_from_domain)
73                    .with_dkim_result(err.clone())
74                    .with_spf_result(err);
75            }
76        };
77        if walk.is_empty() {
78            return DmarcOutput::default().with_domain(rfc5322_from_domain);
79        }
80
81        // Determine the Organizational Domain of the Author Domain
82        let author_org =
83            organizational_domain(&walk, rfc5322_from_domain).unwrap_or(rfc5322_from_domain);
84
85        // Select the DMARC Policy Record to apply: the Author Domain's own
86        // record, otherwise the Organizational Domain's, otherwise the PSD's
87        // (RFC 9989 Section 4.10.1).
88        let (record, is_author_record) =
89            if let Some((_, record)) = walk.iter().find(|(name, _)| *name == rfc5322_from_domain) {
90                (record, true)
91            } else if let Some((_, record)) = walk
92                .iter()
93                .find(|(name, _)| *name == author_org)
94                .or_else(|| walk.last())
95            {
96                (record, false)
97            } else {
98                return DmarcOutput::default().with_domain(rfc5322_from_domain);
99            };
100
101        // Determine the Domain Owner Assessment Policy
102        let mut policy = if is_author_record {
103            // A record published at the Author Domain uses the "p" tag
104            record.p
105        } else if record.np != record.sp
106            && self.domain_exists(rfc5322_from_domain, cache_ipv4).await == Some(false)
107        {
108            // The Author Domain returns NXDOMAIN, i.e. is a non-existent
109            // subdomain (RFC 8020), so "np" applies
110            record.np
111        } else {
112            // The Author Domain is an existing subdomain, so "sp" applies
113            record.sp
114        };
115
116        // A record without a valid "p" tag is treated as "p=none" when a valid
117        // "rua" tag is present, otherwise DMARC does not apply (Section 4.10.1)
118        if policy == Policy::Unspecified {
119            if record.rua.is_empty() {
120                return DmarcOutput::default().with_domain(rfc5322_from_domain);
121            }
122            policy = Policy::None;
123        }
124
125        // In test mode ("t=y") the stated policy is not applied; enforcement is
126        // dropped by one level (RFC 9989 Section 4.7)
127        if record.t {
128            policy = match policy {
129                Policy::Reject => Policy::Quarantine,
130                Policy::Quarantine => Policy::None,
131                other => other,
132            };
133        }
134        let aspf = record.aspf;
135        let adkim = record.adkim;
136
137        let mut output = DmarcOutput {
138            spf_result: DmarcResult::None,
139            dkim_result: DmarcResult::None,
140            domain: rfc5322_from_domain.to_string(),
141            policy,
142            record: None,
143        };
144
145        // Cache Organizational Domains resolved during alignment
146        let mut org_memo: Vec<(&str, &str)> = vec![(rfc5322_from_domain, author_org)];
147
148        if spf_output.result == SpfResult::Pass {
149            // Check SPF alignment (Section 4.10.2)
150            let aligned = rfc5321_mail_from_domain == rfc5322_from_domain
151                || (aspf == Alignment::Relaxed
152                    && self
153                        .organizational_domain_of(
154                            rfc5321_mail_from_domain,
155                            cache_txt,
156                            &mut org_memo,
157                        )
158                        .await
159                        == author_org);
160            output.spf_result = if aligned {
161                DmarcResult::Pass
162            } else {
163                DmarcResult::Fail(Error::NotAligned)
164            };
165        }
166
167        // Check DKIM alignment (Section 4.10.2)
168        let mut has_dkim = false;
169        let mut aligned = false;
170        for d in dkim_output
171            .iter()
172            .filter(|o| o.result == DkimResult::Pass)
173            .filter_map(|o| o.signature.as_ref())
174            .map(|s| s.d.as_str())
175            .chain(
176                dkim2_output
177                    .filter(|o| o.result == Dkim2Result::Pass)
178                    .and_then(|o| {
179                        o.chain
180                            .iter()
181                            .find(|link| link.signature.i == 1 && link.result == Dkim2Result::Pass)
182                            .map(|link| link.signature.d.as_str())
183                    }),
184            )
185        {
186            has_dkim = true;
187            if d == rfc5322_from_domain
188                || (adkim == Alignment::Relaxed
189                    && self
190                        .organizational_domain_of(d, cache_txt, &mut org_memo)
191                        .await
192                        == author_org)
193            {
194                aligned = true;
195                break;
196            }
197        }
198
199        if has_dkim {
200            output.dkim_result = if aligned {
201                DmarcResult::Pass
202            } else {
203                DmarcResult::Fail(Error::NotAligned)
204            };
205        }
206
207        output.with_record(Arc::clone(record))
208    }
209
210    /// Validates the external report e-mail addresses of a DMARC record
211    pub async fn verify_dmarc_report_address<'x, T: AsRef<str>>(
212        &self,
213        domain: &str,
214        addresses: &'x [T],
215        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
216    ) -> Option<Vec<&'x T>> {
217        let mut result = Vec::with_capacity(addresses.len());
218        for address in addresses {
219            let address_ref = address.as_ref();
220            let address_domain = address_ref
221                .rsplit_once('@')
222                .map(|(_, d)| d)
223                .unwrap_or_default();
224            // No external authorization is required when the destination is the
225            // policy domain itself or a subdomain of it.
226            let is_internal = address_domain == domain
227                || address_domain
228                    .strip_suffix(domain)
229                    .is_some_and(|prefix| prefix.ends_with('.'));
230            if is_internal
231                || match self
232                    .txt_lookup::<Dmarc>(
233                        format!("{domain}._report._dmarc.{address_domain}."),
234                        txt_cache,
235                    )
236                    .await
237                {
238                    Ok(_) => true,
239                    Err(Error::Dns(DnsError::Resolver(_))) => return None,
240                    _ => false,
241                }
242            {
243                result.push(address);
244            }
245        }
246
247        result.into()
248    }
249
250    /// Performs a DNS Tree Walk (RFC 9989 Section 4.10) starting at `domain`
251    /// and returns every valid DMARC Policy Record found from the starting
252    /// point (longest name) up to the top-level domain (shortest name).
253    async fn dmarc_tree_walk<'x>(
254        &self,
255        domain: &'x str,
256        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
257    ) -> crate::Result<Vec<(&'x str, Arc<Dmarc>)>> {
258        let total = domain.split('.').filter(|l| !l.is_empty()).count();
259        let mut found = Vec::new();
260        if total < 2 {
261            return Ok(found);
262        }
263
264        // The first query targets the starting point; subsequent queries drop
265        // to 7 labels when the name has 8 or more (the eight-query cap) and then
266        // one label at a time down to the top-level domain.
267        let mut count = total;
268        loop {
269            let name = drop_leftmost_labels(domain, total - count);
270            match self
271                .txt_lookup::<Dmarc>(format!("_dmarc.{name}."), txt_cache)
272                .await
273            {
274                Ok(dmarc) => {
275                    // A record carrying "psd=y" or "psd=n" stops the walk
276                    let stop = matches!(dmarc.psd, Psd::Yes | Psd::No);
277                    found.push((name, dmarc));
278                    if stop {
279                        break;
280                    }
281                }
282                Err(Error::Dns(DnsError::RecordNotFound(_)))
283                | Err(Error::Dns(DnsError::InvalidRecordType)) => (),
284                Err(err) => return Err(err),
285            }
286
287            if count == 1 {
288                break;
289            }
290            count = if count >= 8 { 7 } else { count - 1 };
291        }
292
293        Ok(found)
294    }
295
296    /// Determines whether `domain` exists in the DNS per RFC 8020.
297    async fn domain_exists(
298        &self,
299        domain: &str,
300        cache_ipv4: Option<&impl ResolverCache<Box<str>, RecordSet<Ipv4Addr>>>,
301    ) -> Option<bool> {
302        match self.ipv4_lookup(domain, cache_ipv4).await {
303            // The name resolves to an address: it exists.
304            Ok(_) => Some(true),
305            // NODATA (any RCODE other than NXDOMAIN) means the name exists but
306            // has no A record; only NXDOMAIN means the name does not exist.
307            Err(Error::Dns(DnsError::RecordNotFound(code))) => {
308                Some(code != crate::DNS_RCODE_NXDOMAIN)
309            }
310            Err(_) => None,
311        }
312    }
313
314    /// Determines the Organizational Domain of `domain` via a DNS Tree Walk (RFC 9989 Section 4.10.2).
315    async fn organizational_domain_of<'x>(
316        &self,
317        domain: &'x str,
318        txt_cache: Option<&impl ResolverCache<Box<str>, Txt>>,
319        memo: &mut Vec<(&'x str, &'x str)>,
320    ) -> &'x str {
321        if let Some(&(_, org)) = memo.iter().find(|(d, _)| *d == domain) {
322            return org;
323        }
324        let org = match self.dmarc_tree_walk(domain, txt_cache).await {
325            Ok(walk) => organizational_domain(&walk, domain).unwrap_or(domain),
326            Err(_) => domain,
327        };
328        memo.push((domain, org));
329        org
330    }
331}
332
333/// Selects the Organizational Domain from the set of DMARC Policy Records
334/// retrieved by a Tree Walk (RFC 9989 Section 4.10.2). The `walk` is ordered
335/// from the longest name (the starting domain) to the shortest.
336fn organizational_domain<'x>(walk: &[(&'x str, Arc<Dmarc>)], start: &'x str) -> Option<&'x str> {
337    for (name, record) in walk {
338        match record.psd {
339            Psd::No => return Some(name),
340            Psd::Yes if *name != start => return Some(one_label_below(name, start)),
341            _ => {}
342        }
343    }
344    walk.last().map(|(name, _)| *name)
345}
346
347/// Returns the domain one label below `psd_name` on the path toward `start`.
348fn one_label_below<'x>(psd_name: &str, start: &'x str) -> &'x str {
349    let depth = psd_name.split('.').filter(|l| !l.is_empty()).count() + 1;
350    let start_labels = start.split('.').filter(|l| !l.is_empty()).count();
351    drop_leftmost_labels(start, start_labels.saturating_sub(depth))
352}
353
354/// Returns the suffix of `domain` after removing its `n` leftmost labels.
355fn drop_leftmost_labels(domain: &str, n: usize) -> &str {
356    let mut suffix = domain;
357    for _ in 0..n {
358        match suffix.split_once('.') {
359            Some((_, rest)) => suffix = rest,
360            None => return "",
361        }
362    }
363    suffix
364}
365
366impl<'x> DmarcParameters<'x> {
367    pub fn new(
368        message: &'x AuthenticatedMessage<'x>,
369        dkim_output: &'x [DkimOutput<'x>],
370        rfc5321_mail_from_domain: &'x str,
371        spf_output: &'x SpfOutput,
372    ) -> Self {
373        Self {
374            message,
375            dkim_output,
376            dkim2_output: None,
377            rfc5321_mail_from_domain,
378            spf_output,
379        }
380    }
381
382    pub fn with_dkim2_output(mut self, dkim2_output: &'x Dkim2Output<'x>) -> Self {
383        self.dkim2_output = Some(dkim2_output);
384        self
385    }
386}
387
388impl<'x> From<DmarcParameters<'x>>
389    for Parameters<
390        'x,
391        DmarcParameters<'x>,
392        NoCache<Box<str>, Txt>,
393        NoCache<Box<str>, RecordSet<MX>>,
394        NoCache<Box<str>, RecordSet<Ipv4Addr>>,
395        NoCache<Box<str>, RecordSet<Ipv6Addr>>,
396        NoCache<IpAddr, RecordSet<Box<str>>>,
397    >
398{
399    fn from(params: DmarcParameters<'x>) -> Self {
400        Parameters::new(params)
401    }
402}
403
404#[cfg(test)]
405#[allow(unused)]
406mod test {
407    use super::DmarcParameters;
408    use crate::{
409        AuthenticatedMessage, DkimOutput, DkimResult, DmarcResult, Error, MessageAuthenticator,
410        SpfOutput, SpfResult,
411        common::{cache::test::DummyCaches, parse::TxtRecordParser},
412        dkim::{DkimError, Signature},
413        dmarc::{Dmarc, Policy, URI},
414    };
415    use mail_parser::MessageParser;
416    use std::time::{Duration, Instant};
417
418    #[tokio::test]
419    async fn dmarc_verify_alignment() {
420        let resolver = MessageAuthenticator::new_system_conf().unwrap();
421        let caches = DummyCaches::new();
422
423        for (
424            dmarc_dns,
425            dmarc,
426            message,
427            rfc5321_mail_from_domain,
428            signature_domain,
429            dkim,
430            spf,
431            expect_dkim,
432            expect_spf,
433            policy,
434        ) in [
435            // Strict - Pass
436            (
437                "_dmarc.example.org.",
438                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
439                "From: hello@example.org\r\n\r\n",
440                "example.org",
441                "example.org",
442                DkimResult::Pass,
443                SpfResult::Pass,
444                DmarcResult::Pass,
445                DmarcResult::Pass,
446                Policy::Reject,
447            ),
448            // Relaxed - Pass on the Organizational Domain
449            (
450                "_dmarc.example.org.",
451                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
452                "From: hello@example.org\r\n\r\n",
453                "subdomain.example.org",
454                "subdomain.example.org",
455                DkimResult::Pass,
456                SpfResult::Pass,
457                DmarcResult::Pass,
458                DmarcResult::Pass,
459                Policy::Reject,
460            ),
461            // Strict - Fail (subdomain identifiers do not match exactly)
462            (
463                "_dmarc.example.org.",
464                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
465                "From: hello@example.org\r\n\r\n",
466                "subdomain.example.org",
467                "subdomain.example.org",
468                DkimResult::Pass,
469                SpfResult::Pass,
470                DmarcResult::Fail(Error::NotAligned),
471                DmarcResult::Fail(Error::NotAligned),
472                Policy::Reject,
473            ),
474            // Failed mechanisms produce no aligned result
475            (
476                "_dmarc.example.org.",
477                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
478                "From: hello@example.org\r\n\r\n",
479                "example.org",
480                "example.org",
481                DkimResult::Fail(Error::Dkim(DkimError::SignatureExpired)),
482                SpfResult::Fail,
483                DmarcResult::None,
484                DmarcResult::None,
485                Policy::Reject,
486            ),
487        ] {
488            caches.txt_add(
489                dmarc_dns,
490                Dmarc::parse(dmarc.as_bytes()).unwrap(),
491                Instant::now() + Duration::new(3200, 0),
492            );
493
494            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
495            let signature = Signature {
496                d: signature_domain.into(),
497                ..Default::default()
498            };
499            let dkim = DkimOutput {
500                result: dkim,
501                signature: (&signature).into(),
502                report: None,
503                is_atps: false,
504            };
505            let spf = SpfOutput {
506                result: spf,
507                domain: rfc5321_mail_from_domain.to_string(),
508                report: None,
509                explanation: None,
510            };
511            let result = resolver
512                .verify_dmarc(caches.parameters(DmarcParameters::new(
513                    &auth_message,
514                    &[dkim],
515                    rfc5321_mail_from_domain,
516                    &spf,
517                )))
518                .await;
519            assert_eq!(result.dkim_result, expect_dkim, "dkim {message}");
520            assert_eq!(result.spf_result, expect_spf, "spf {message}");
521            assert_eq!(result.policy, policy, "policy {message}");
522        }
523    }
524
525    #[tokio::test]
526    async fn dmarc_policy_discovery() {
527        let resolver = MessageAuthenticator::new_system_conf().unwrap();
528        let expires = Instant::now() + Duration::new(3200, 0);
529
530        // Author Domain has its own record -> "p" applies
531        let caches = DummyCaches::new();
532        caches.txt_add(
533            "_dmarc.example.org.",
534            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
535            expires,
536        );
537        assert_eq!(
538            policy_of(&resolver, &caches, "hello@example.org").await,
539            Policy::Reject,
540        );
541
542        // Existing subdomain, only the Organizational Domain publishes a
543        // record -> "sp" applies
544        let caches = DummyCaches::new();
545        caches.txt_add(
546            "_dmarc.example.org.",
547            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
548            expires,
549        );
550        caches.ipv4_add("sub.example.org.", vec![[127, 0, 0, 1].into()], expires);
551        assert_eq!(
552            policy_of(&resolver, &caches, "hello@sub.example.org").await,
553            Policy::Quarantine,
554        );
555
556        // Non-existent subdomain -> "np" applies
557        let caches = DummyCaches::new();
558        caches.txt_add(
559            "_dmarc.example.org.",
560            Dmarc::parse(b"v=DMARC1; p=reject; sp=quarantine; np=none").unwrap(),
561            expires,
562        );
563        assert_eq!(
564            policy_of(&resolver, &caches, "hello@ghost.example.org").await,
565            Policy::None,
566        );
567
568        // Missing "p" with a valid "rua" -> treated as "p=none"
569        let caches = DummyCaches::new();
570        caches.txt_add(
571            "_dmarc.example.org.",
572            Dmarc::parse(b"v=DMARC1; rua=mailto:d@example.org").unwrap(),
573            expires,
574        );
575        assert_eq!(
576            policy_of(&resolver, &caches, "hello@example.org").await,
577            Policy::None,
578        );
579
580        // No record at all -> DMARC does not apply
581        let caches = DummyCaches::new();
582        let result = verify(&resolver, &caches, "hello@nothing.example").await;
583        assert_eq!(result.dmarc_record(), None);
584    }
585
586    #[tokio::test]
587    async fn dmarc_tree_walk_psd() {
588        let resolver = MessageAuthenticator::new_system_conf().unwrap();
589        let expires = Instant::now() + Duration::new(3200, 0);
590
591        // "psd=n" marks the Organizational Domain: relaxed alignment between
592        // "a.mail.example.com" and an identifier under "mail.example.com" holds
593        let caches = DummyCaches::new();
594        caches.txt_add(
595            "_dmarc.mail.example.com.",
596            Dmarc::parse(b"v=DMARC1; p=reject; psd=n; rua=mailto:d@example.com").unwrap(),
597            expires,
598        );
599        caches.ipv4_add("a.mail.example.com.", vec![[127, 0, 0, 1].into()], expires);
600        let result = verify_aligned(
601            &resolver,
602            &caches,
603            "hello@a.mail.example.com",
604            "b.mail.example.com",
605        )
606        .await;
607        assert_eq!(result.spf_result(), &DmarcResult::Pass);
608
609        // "psd=y" pushes the Organizational Domain one label below, so
610        // "giant.bank.example" and "mega.bank.example" are different
611        // Organizational Domains and do not align
612        let caches = DummyCaches::new();
613        caches.txt_add(
614            "_dmarc.bank.example.",
615            Dmarc::parse(b"v=DMARC1; p=reject; psd=y; rua=mailto:d@bank.example").unwrap(),
616            expires,
617        );
618        caches.txt_add(
619            "_dmarc.giant.bank.example.",
620            Dmarc::parse(b"v=DMARC1; p=reject; rua=mailto:d@giant.bank.example").unwrap(),
621            expires,
622        );
623        let result = verify_aligned(
624            &resolver,
625            &caches,
626            "hello@giant.bank.example",
627            "mega.bank.example",
628        )
629        .await;
630        assert_eq!(result.spf_result(), &DmarcResult::Fail(Error::NotAligned));
631    }
632
633    #[tokio::test]
634    async fn dmarc_tree_walk_query_cap() {
635        let resolver = MessageAuthenticator::new_system_conf().unwrap();
636        let expires = Instant::now() + Duration::new(3200, 0);
637
638        // A record published between the Author Domain and the 7-labels-remaining
639        // shortcut is never discovered, but "example.com" is reached within the
640        // eight-query budget.
641        let caches = DummyCaches::new();
642        caches.txt_add(
643            "_dmarc.example.com.",
644            Dmarc::parse(b"v=DMARC1; p=reject; np=none; rua=mailto:d@example.com").unwrap(),
645            expires,
646        );
647        assert_eq!(
648            policy_of(
649                &resolver,
650                &caches,
651                "hello@a.b.c.d.e.f.g.h.i.j.mail.example.com",
652            )
653            .await,
654            Policy::None,
655        );
656    }
657
658    async fn verify(
659        resolver: &MessageAuthenticator,
660        caches: &DummyCaches,
661        from: &str,
662    ) -> DmarcOutputHelper {
663        verify_aligned(resolver, caches, from, "").await
664    }
665
666    async fn verify_aligned(
667        resolver: &MessageAuthenticator,
668        caches: &DummyCaches,
669        from: &str,
670        mail_from_domain: &str,
671    ) -> DmarcOutputHelper {
672        let message = format!("From: {from}\r\n\r\n");
673        let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
674        let spf = SpfOutput {
675            result: SpfResult::Pass,
676            domain: mail_from_domain.to_string(),
677            report: None,
678            explanation: None,
679        };
680        resolver
681            .verify_dmarc(caches.parameters(DmarcParameters::new(
682                &auth_message,
683                &[],
684                mail_from_domain,
685                &spf,
686            )))
687            .await
688    }
689
690    async fn policy_of(
691        resolver: &MessageAuthenticator,
692        caches: &DummyCaches,
693        from: &str,
694    ) -> Policy {
695        verify(resolver, caches, from).await.policy()
696    }
697
698    type DmarcOutputHelper = crate::DmarcOutput;
699
700    #[tokio::test]
701    async fn dmarc_verify_dkim2() {
702        use crate::Dkim2Result;
703        use crate::dkim2::{ChainLink, Dkim2Output, Signature as Dkim2Signature};
704
705        let resolver = MessageAuthenticator::new_system_conf().unwrap();
706        let caches = DummyCaches::new();
707
708        for (dmarc_dns, dmarc, message, signature_domain, dkim2_result, expect_dkim, policy) in [
709            // Strict - Pass
710            (
711                "_dmarc.example.org.",
712                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
713                "From: hello@example.org\r\n\r\n",
714                "example.org",
715                Dkim2Result::Pass,
716                DmarcResult::Pass,
717                Policy::Reject,
718            ),
719            // Relaxed - Pass on the Organizational Domain
720            (
721                "_dmarc.example.org.",
722                "v=DMARC1; p=reject; aspf=r; adkim=r; fo=1; rua=mailto:d@example.org",
723                "From: hello@example.org\r\n\r\n",
724                "subdomain.example.org",
725                Dkim2Result::Pass,
726                DmarcResult::Pass,
727                Policy::Reject,
728            ),
729            // Strict - Fail (subdomain does not match exactly)
730            (
731                "_dmarc.example.org.",
732                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
733                "From: hello@example.org\r\n\r\n",
734                "subdomain.example.org",
735                Dkim2Result::Pass,
736                DmarcResult::Fail(Error::NotAligned),
737                Policy::Reject,
738            ),
739            // Chain did not verify - no DKIM alignment
740            (
741                "_dmarc.example.org.",
742                "v=DMARC1; p=reject; aspf=s; adkim=s; fo=1; rua=mailto:d@example.org",
743                "From: hello@example.org\r\n\r\n",
744                "example.org",
745                Dkim2Result::Fail(Error::NotAligned),
746                DmarcResult::None,
747                Policy::Reject,
748            ),
749        ] {
750            caches.txt_add(
751                dmarc_dns,
752                Dmarc::parse(dmarc.as_bytes()).unwrap(),
753                Instant::now() + Duration::new(3200, 0),
754            );
755
756            let auth_message = AuthenticatedMessage::parse(message.as_bytes()).unwrap();
757            let signature = Dkim2Signature {
758                i: 1,
759                d: signature_domain.into(),
760                ..Default::default()
761            };
762            let dkim2 = Dkim2Output {
763                result: dkim2_result.clone(),
764                chain: vec![ChainLink {
765                    signature: &signature,
766                    instance: None,
767                    result: dkim2_result,
768                    custody_ok: true,
769                }],
770            };
771            let spf = SpfOutput {
772                result: SpfResult::None,
773                domain: "example.org".to_string(),
774                report: None,
775                explanation: None,
776            };
777            let result = resolver
778                .verify_dmarc(
779                    caches.parameters(
780                        DmarcParameters::new(&auth_message, &[], "example.org", &spf)
781                            .with_dkim2_output(&dkim2),
782                    ),
783                )
784                .await;
785            assert_eq!(result.dkim_result, expect_dkim);
786            assert_eq!(result.policy, policy);
787        }
788    }
789
790    #[tokio::test]
791    async fn dmarc_verify_report_address() {
792        let resolver = MessageAuthenticator::new_system_conf().unwrap();
793        let caches = DummyCaches::new().with_txt(
794            "example.org._report._dmarc.external.org.",
795            Dmarc::parse(b"v=DMARC1").unwrap(),
796            Instant::now() + Duration::new(3200, 0),
797        );
798        let uris = vec![
799            URI::new("dmarc@example.org", 0),
800            URI::new("dmarc@external.org", 0),
801            URI::new("domain@other.org", 0),
802        ];
803
804        assert_eq!(
805            resolver
806                .verify_dmarc_report_address("example.org", &uris, Some(&caches.txt))
807                .await
808                .unwrap(),
809            vec![
810                &URI::new("dmarc@example.org", 0),
811                &URI::new("dmarc@external.org", 0),
812            ]
813        );
814    }
815}