Skip to main content

seer_core/
posture.rs

1//! Email / DNS security-posture inspection.
2//!
3//! Resolves and interprets the DNS-published mail-security policies for a
4//! domain — SPF, DMARC, MTA-STS, BIMI, and DANE (TLSA) — and returns a
5//! per-mechanism verdict with advisory notes. A lax or absent DMARC policy is
6//! the single strongest signal that a domain is spoofable, which directly feeds
7//! phishing-risk assessment.
8//!
9//! This mirrors [`crate::caa`]'s tree-walk-plus-advisory shape and reuses the
10//! [`DnsResolver`] TXT/MX/TLSA path — no new network protocol. The follow-up
11//! lookups the RFCs require (SPF `redirect=`, DMARC's organizational-domain
12//! fallback, DANE at the MX hosts) are DNS-only and bounded. All parsing is
13//! pure and unit-tested; only the record fetch is async.
14//!
15//! Beyond verdicts and notes, the result carries structured signals for
16//! machine consumers: [`SpfPolicy::permerror`] (why SPF evaluation fails),
17//! [`DmarcPolicy::effective_policy`] (the DMARC policy that actually applies,
18//! after `sp=` inheritance) and [`EmailPosture::mx`] (hosts, null MX, absent
19//! or unknown).
20
21use std::collections::HashMap;
22
23use serde::{Deserialize, Serialize};
24
25use crate::dns::{DnsResolver, RecordData, RecordType};
26use crate::error::Result;
27use crate::validation::{normalize_domain, normalize_host};
28
29/// A coarse enforcement verdict for one posture mechanism, HTTP security
30/// header, or cookie. `seer headers` grades on this same scale (exported
31/// there as [`crate::headers::HeaderVerdict`]) so both reports read alike.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum PostureVerdict {
35    /// Not configured / not sent at all.
36    Absent,
37    /// Configured, but permissive / monitoring-only (offers little protection).
38    Weak,
39    /// Configured with partial enforcement.
40    Moderate,
41    /// Configured with full enforcement.
42    Strict,
43    /// Configured; the mechanism has no weak/strict axis (presence is the signal).
44    Present,
45    /// The DNS lookup failed (SERVFAIL, timeout, …), so the state is not
46    /// known. Posture only: never read it as `Absent` — a transient resolver
47    /// error is no evidence that the domain is spoofable.
48    Unknown,
49}
50
51impl PostureVerdict {
52    /// The kebab-case label serde emits, for text renderers and CSV cells.
53    pub fn as_str(self) -> &'static str {
54        match self {
55            PostureVerdict::Absent => "absent",
56            PostureVerdict::Weak => "weak",
57            PostureVerdict::Moderate => "moderate",
58            PostureVerdict::Strict => "strict",
59            PostureVerdict::Present => "present",
60            PostureVerdict::Unknown => "unknown",
61        }
62    }
63}
64
65/// SPF (Sender Policy Framework) posture.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct SpfPolicy {
68    pub present: bool,
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub record: Option<String>,
71    /// The qualifier on the terminal `all` mechanism: `-` (fail), `~`
72    /// (softfail), `?` (neutral), `+` (pass — permits anyone).
73    #[serde(skip_serializing_if = "Option::is_none")]
74    pub all_qualifier: Option<String>,
75    pub verdict: PostureVerdict,
76    /// Set when SPF evaluation ends in permerror (the record is then useless).
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub permerror: Option<SpfPermerror>,
79}
80
81/// Why SPF evaluation ends in `permerror` (RFC 7208 §2.6.7): receivers get no
82/// SPF result at all, whatever the record says.
83#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum SpfPermerror {
86    /// More than one `v=spf1` record (RFC 7208 §4.5).
87    MultipleRecords,
88    /// More than 10 DNS-querying terms (RFC 7208 §4.6.4).
89    TooManyLookups,
90    /// The `redirect=` target could not be evaluated.
91    RedirectFailed,
92}
93
94impl SpfPermerror {
95    /// Short plain-English cause, for evidence lines.
96    pub fn describe(self) -> &'static str {
97        match self {
98            SpfPermerror::MultipleRecords => "more than one SPF record is published",
99            SpfPermerror::TooManyLookups => "SPF needs more than 10 DNS lookups",
100            SpfPermerror::RedirectFailed => "the SPF redirect= target could not be evaluated",
101        }
102    }
103}
104
105/// The domain's MX state as the posture lookup saw it.
106#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "state", content = "hosts", rename_all = "kebab-case")]
108pub enum MailExchangers {
109    /// MX hosts (lowercase, no trailing dot), in preference order.
110    Hosts(Vec<String>),
111    /// A null MX (RFC 7505): the domain accepts no mail.
112    Null,
113    /// No MX records: mail falls back to the domain's address (RFC 5321 §5.1).
114    Absent,
115    /// The MX lookup failed, so the state is not known.
116    #[default]
117    Unknown,
118}
119
120/// DMARC posture.
121#[derive(Debug, Clone, Serialize, Deserialize)]
122pub struct DmarcPolicy {
123    pub present: bool,
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub record: Option<String>,
126    /// The `p=` policy: `none`, `quarantine`, or `reject`.
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub policy: Option<String>,
129    /// The `sp=` subdomain policy, if set.
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub subdomain_policy: Option<String>,
132    /// The policy receivers apply to this domain: `sp=` when the record is
133    /// inherited from the organizational domain and sets one, else `p=`
134    /// (RFC 7489 §6.6.3). `None` when no single record applies (none, or
135    /// several published). `verdict` grades this value.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub effective_policy: Option<String>,
138    /// Aggregate report (`rua=`) destinations.
139    #[serde(skip_serializing_if = "Vec::is_empty")]
140    pub aggregate_reports: Vec<String>,
141    /// The `pct=` percentage of mail the policy applies to (defaults to 100).
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub percent: Option<u8>,
144    pub verdict: PostureVerdict,
145}
146
147/// MTA-STS posture (DNS signal only; enforcement mode lives in the HTTPS
148/// policy file and is not fetched here to avoid an outbound SSRF surface).
149#[derive(Debug, Clone, Serialize, Deserialize)]
150pub struct MtaStsPolicy {
151    pub present: bool,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub record: Option<String>,
154    /// The policy `id=` from the TXT record (changes when the policy updates).
155    #[serde(skip_serializing_if = "Option::is_none")]
156    pub id: Option<String>,
157    pub verdict: PostureVerdict,
158}
159
160/// BIMI posture.
161#[derive(Debug, Clone, Serialize, Deserialize)]
162pub struct BimiPolicy {
163    pub present: bool,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub record: Option<String>,
166    /// The `l=` logo URL.
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub logo_url: Option<String>,
169    /// The `a=` VMC (verified mark certificate) authority URL.
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub authority_url: Option<String>,
172    pub verdict: PostureVerdict,
173}
174
175/// A single DANE TLSA record with the port/protocol scope it was found at.
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct TlsaRecord {
178    /// The owner scope: `_25._tcp.<mx-host>` for SMTP records at an MX host
179    /// (RFC 7672), `_25._tcp` for a domain without MX (its implicit MX is the
180    /// domain itself), or `_443._tcp` (HTTPS).
181    pub scope: String,
182    pub cert_usage: u8,
183    pub selector: u8,
184    pub matching: u8,
185    pub cert_data: String,
186}
187
188/// DANE (TLSA) posture across the common SMTP and HTTPS scopes.
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub struct DanePolicy {
191    pub present: bool,
192    #[serde(skip_serializing_if = "Vec::is_empty")]
193    pub records: Vec<TlsaRecord>,
194    pub verdict: PostureVerdict,
195}
196
197/// The aggregate email/DNS security posture for a domain.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct EmailPosture {
200    pub domain: String,
201    pub spf: SpfPolicy,
202    pub dmarc: DmarcPolicy,
203    pub mta_sts: MtaStsPolicy,
204    pub bimi: BimiPolicy,
205    pub dane: DanePolicy,
206    /// MX state, read once for DANE and reused by consumers (the audit's
207    /// "receives mail?" questions).
208    #[serde(default)]
209    pub mx: MailExchangers,
210    /// Human-readable advisory findings (e.g. "domain is spoofable").
211    pub notes: Vec<String>,
212}
213
214// --- Bounds -----------------------------------------------------------------
215
216/// The SPF DNS-lookup limit (RFC 7208 §4.6.4): evaluation that needs more
217/// than this many `include`/`a`/`mx`/`ptr`/`exists`/`redirect` terms is a
218/// permerror. It also bounds the `redirect=` hops and `include:` records
219/// seer fetches — each one counts toward it. Every fetch is one DNS TXT
220/// query — no outbound connection, so no SSRF surface.
221const MAX_SPF_LOOKUPS: usize = 10;
222
223/// Maximum parent names tried when looking for an inherited DMARC record
224/// (the walk also never leaves the organizational domain).
225const MAX_DMARC_WALK: usize = 7;
226
227/// Maximum MX hosts (in preference order) probed for SMTP DANE records.
228const MAX_DANE_MX_HOSTS: usize = 5;
229
230// --- Pure parsers -------------------------------------------------------
231
232/// True when `record` begins with the version tag `tag` (ASCII
233/// case-insensitive) followed by the end of the record, whitespace, or —
234/// for the `k=v;` policy records (`allow_semicolon`) — a `;`. A bare prefix
235/// test accepted `v=spf10 …` as SPF.
236fn has_version_tag(record: &str, tag: &str, allow_semicolon: bool) -> bool {
237    let record = record.trim_start();
238    let Some(head) = record.get(..tag.len()) else {
239        return false;
240    };
241    if !head.eq_ignore_ascii_case(tag) {
242        return false;
243    }
244    match record[tag.len()..].chars().next() {
245        None => true,
246        Some(c) => c.is_whitespace() || (allow_semicolon && c == ';'),
247    }
248}
249
250/// How many records in a TXT set carry a given version tag.
251#[derive(Debug, PartialEq, Eq)]
252enum Selection<'a> {
253    None,
254    One(&'a str),
255    /// More than one — SPF evaluation is a permerror (RFC 7208 §4.5) and
256    /// DMARC is not applied at all (RFC 7489 §6.6.3).
257    Multiple(usize),
258}
259
260fn select<'a>(records: &'a [String], tag: &str, allow_semicolon: bool) -> Selection<'a> {
261    let mut matching = records
262        .iter()
263        .filter(|r| has_version_tag(r, tag, allow_semicolon));
264    match (matching.next(), matching.count()) {
265        (None, _) => Selection::None,
266        (Some(record), 0) => Selection::One(record),
267        (Some(_), rest) => Selection::Multiple(rest + 1),
268    }
269}
270
271fn select_spf(records: &[String]) -> Selection<'_> {
272    select(records, "v=spf1", false)
273}
274
275fn select_dmarc(records: &[String]) -> Selection<'_> {
276    select(records, "v=DMARC1", true)
277}
278
279/// Returns the qualifier on the terminal `all` mechanism of an SPF record
280/// (`-`, `~`, `?`, or `+`), or `None` if the record has no `all` mechanism.
281fn parse_spf_all_qualifier(record: &str) -> Option<String> {
282    for token in record.split_whitespace() {
283        let t = token.to_ascii_lowercase();
284        if t == "all" {
285            return Some("+".to_string());
286        }
287        if let Some(qual) = t.strip_suffix("all") {
288            if matches!(qual, "-" | "~" | "?" | "+") {
289                return Some(qual.to_string());
290            }
291        }
292    }
293    None
294}
295
296/// The target of an SPF `redirect=` modifier, if any.
297fn parse_spf_redirect(record: &str) -> Option<String> {
298    record.split_whitespace().find_map(|token| {
299        let (name, value) = token.split_once('=')?;
300        name.eq_ignore_ascii_case("redirect")
301            .then(|| value.to_string())
302    })
303}
304
305/// What decides a single SPF record's verdict.
306#[derive(Debug, PartialEq, Eq)]
307enum SpfTerminal {
308    /// The `all` mechanism's qualifier.
309    All(String),
310    /// No `all`, but a `redirect=` modifier: the target record governs
311    /// (RFC 7208 §6.1 — `redirect` is ignored when `all` is present).
312    Redirect(String),
313    /// Neither: the default result is neutral, i.e. no protection.
314    Neither,
315}
316
317fn spf_terminal(record: &str) -> SpfTerminal {
318    match (parse_spf_all_qualifier(record), parse_spf_redirect(record)) {
319        (Some(qualifier), _) => SpfTerminal::All(qualifier),
320        (None, Some(target)) => SpfTerminal::Redirect(target),
321        (None, None) => SpfTerminal::Neither,
322    }
323}
324
325/// The DNS-querying terms of one SPF record (RFC 7208 §4.6.4): how many it
326/// has, and the `include:` / effective `redirect=` targets whose records
327/// count too.
328fn spf_lookup_terms(record: &str) -> (usize, Vec<String>) {
329    let mut count = 0;
330    let mut targets = Vec::new();
331    // The first token is the `v=spf1` version tag.
332    for token in record.split_whitespace().skip(1) {
333        let term = token.trim_start_matches(['+', '-', '~', '?']);
334        if term.contains('=') {
335            continue; // a modifier; `redirect=` is handled below
336        }
337        let name = term.split([':', '/']).next().unwrap_or(term);
338        if name.eq_ignore_ascii_case("include") {
339            count += 1;
340            targets.extend(term.split_once(':').map(|(_, t)| t.to_string()));
341        } else if ["a", "mx", "ptr", "exists"]
342            .iter()
343            .any(|m| name.eq_ignore_ascii_case(m))
344        {
345            count += 1;
346        }
347    }
348    // `redirect=` is only used (and so only counted) without an `all`.
349    if let SpfTerminal::Redirect(target) = spf_terminal(record) {
350        count += 1;
351        targets.push(target);
352    }
353    (count, targets)
354}
355
356/// Maps an SPF `all` qualifier to a verdict.
357fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
358    match all_qualifier {
359        Some("-") => PostureVerdict::Strict,
360        Some("~") => PostureVerdict::Moderate,
361        // "?all" (neutral), "+all" (pass-all), or a record with no `all` term
362        // provides effectively no protection.
363        Some(_) | None => PostureVerdict::Weak,
364    }
365}
366
367/// Splits a `k=v; k=v` policy record (DMARC/BIMI/MTA-STS) into a tag map with
368/// lowercased keys.
369fn parse_tag_value(record: &str) -> HashMap<String, String> {
370    record
371        .split(';')
372        .filter_map(|pair| {
373            let (k, v) = pair.split_once('=')?;
374            Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
375        })
376        .collect()
377}
378
379/// Maps a DMARC `p=`/`sp=` policy to a verdict.
380fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
381    match policy.map(|p| p.to_ascii_lowercase()) {
382        Some(ref p) if p == "reject" => PostureVerdict::Strict,
383        Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
384        // `none`, a missing or an invalid policy: monitoring only.
385        _ => PostureVerdict::Weak,
386    }
387}
388
389/// One enforcement band weaker (a `pct=` below 100 applies the policy to only
390/// part of the failing mail; RFC 7489 §6.6.4 gives the rest the next-lower
391/// policy).
392fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
393    match verdict {
394        PostureVerdict::Strict => PostureVerdict::Moderate,
395        PostureVerdict::Moderate => PostureVerdict::Weak,
396        other => other,
397    }
398}
399
400/// Extracts the TXT record strings from a resolved record set.
401fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
402    records
403        .iter()
404        .filter_map(|r| match &r.data {
405            RecordData::TXT { text, .. } => Some(text.clone()),
406            _ => None,
407        })
408        .collect()
409}
410
411// --- Assemblers (pure given the fetched records) -----------------------
412
413/// A mechanism's policy plus the advisory notes it contributes.
414#[derive(Debug)]
415struct Assessed<T> {
416    policy: T,
417    notes: Vec<String>,
418}
419
420/// A TXT record set, or the sanitized reason its lookup failed.
421type TxtLookup = std::result::Result<Vec<String>, String>;
422
423/// The result of following an SPF `redirect=` chain.
424#[derive(Debug)]
425enum RedirectOutcome {
426    /// The chain ended at `target`, whose record's `all` qualifier (if any)
427    /// is the effective one.
428    Resolved {
429        target: String,
430        qualifier: Option<String>,
431    },
432    /// The chain cannot be evaluated (a permerror, or a macro seer does not
433    /// expand).
434    Failed { target: String, reason: String },
435    /// A DNS lookup on the chain failed: the effective policy is unknown.
436    LookupFailed { target: String, reason: String },
437}
438
439/// What SPF evaluation found beyond the apex record itself.
440#[derive(Debug, Default)]
441struct SpfFollowUp {
442    /// The `redirect=` outcome, when the record has one and no `all`.
443    redirect: Option<RedirectOutcome>,
444    /// DNS-querying terms counted across the record and the records it
445    /// includes or redirects to (stops once past [`MAX_SPF_LOOKUPS`]).
446    lookups: usize,
447}
448
449fn spf_verdict_note(verdict: PostureVerdict) -> Option<String> {
450    match verdict {
451        PostureVerdict::Weak => Some(
452            "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
453        ),
454        PostureVerdict::Moderate => {
455            Some("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string())
456        }
457        _ => None,
458    }
459}
460
461/// The SPF policy when the apex TXT lookup itself failed.
462fn unknown_spf(reason: &str) -> Assessed<SpfPolicy> {
463    Assessed {
464        policy: SpfPolicy {
465            present: false,
466            record: None,
467            all_qualifier: None,
468            verdict: PostureVerdict::Unknown,
469            permerror: None,
470        },
471        notes: vec![format!(
472            "SPF lookup failed ({reason}) — SPF posture is unknown, not absent."
473        )],
474    }
475}
476
477/// Assembles the SPF policy from the apex TXT set and what evaluating its
478/// record found (see [`assess_spf`]).
479fn build_spf(apex_txt: &[String], follow_up: &SpfFollowUp) -> Assessed<SpfPolicy> {
480    let record = match select_spf(apex_txt) {
481        Selection::None => {
482            return Assessed {
483                policy: SpfPolicy {
484                    present: false,
485                    record: None,
486                    all_qualifier: None,
487                    verdict: PostureVerdict::Absent,
488                    permerror: None,
489                },
490                notes: vec!["No SPF record — sending sources are undeclared.".to_string()],
491            };
492        }
493        Selection::Multiple(count) => {
494            return Assessed {
495                policy: SpfPolicy {
496                    present: true,
497                    record: None,
498                    all_qualifier: None,
499                    verdict: PostureVerdict::Weak,
500                    permerror: Some(SpfPermerror::MultipleRecords),
501                },
502                notes: vec![format!(
503                    "{count} SPF records are published — SPF evaluation fails with permerror \
504                     (RFC 7208 §4.5), so receivers get no SPF result. Merge them into one record."
505                )],
506            };
507        }
508        Selection::One(record) => record,
509    };
510
511    // A policy whose verdict is decided before its `all` qualifier.
512    let settled =
513        |verdict: PostureVerdict, permerror: Option<SpfPermerror>, note: String| Assessed {
514            policy: SpfPolicy {
515                present: true,
516                record: Some(record.to_string()),
517                all_qualifier: None,
518                verdict,
519                permerror,
520            },
521            notes: vec![note],
522        };
523
524    if follow_up.lookups > MAX_SPF_LOOKUPS {
525        return settled(
526            PostureVerdict::Weak,
527            Some(SpfPermerror::TooManyLookups),
528            format!(
529                "SPF needs more than {MAX_SPF_LOOKUPS} DNS lookups (include/a/mx/ptr/exists/\
530                 redirect, counted through its includes) — evaluation fails with permerror \
531                 (RFC 7208 §4.6.4), so receivers get no SPF result. Flatten or trim the record."
532            ),
533        );
534    }
535
536    let mut notes = Vec::new();
537    let all_qualifier = match (spf_terminal(record), &follow_up.redirect) {
538        (SpfTerminal::All(qualifier), _) => Some(qualifier),
539        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Resolved { target, qualifier })) => {
540            notes.push(format!(
541                "SPF delegates to {target} via redirect= — the verdict reflects that record."
542            ));
543            qualifier.clone()
544        }
545        (SpfTerminal::Redirect(_), Some(RedirectOutcome::Failed { target, reason })) => {
546            return settled(
547                PostureVerdict::Weak,
548                Some(SpfPermerror::RedirectFailed),
549                format!(
550                    "SPF redirect={target} could not be evaluated ({reason}) — receivers get \
551                     no usable SPF result."
552                ),
553            );
554        }
555        (SpfTerminal::Redirect(_), Some(RedirectOutcome::LookupFailed { target, reason })) => {
556            return settled(
557                PostureVerdict::Unknown,
558                None,
559                format!(
560                    "SPF redirect={target} lookup failed ({reason}) — the effective SPF policy \
561                     is unknown."
562                ),
563            );
564        }
565        (SpfTerminal::Redirect(_), None) | (SpfTerminal::Neither, _) => None,
566    };
567    let verdict = spf_verdict(all_qualifier.as_deref());
568    notes.extend(spf_verdict_note(verdict));
569    Assessed {
570        policy: SpfPolicy {
571            present: true,
572            verdict,
573            all_qualifier,
574            record: Some(record.to_string()),
575            permerror: None,
576        },
577        notes,
578    }
579}
580
581/// Where the applicable DMARC record set was found.
582#[derive(Debug)]
583struct DmarcSource {
584    /// The TXT strings at `_dmarc.<at>`.
585    txts: Vec<String>,
586    /// The domain whose `_dmarc` name held the records.
587    at: String,
588    /// True when `at` is a parent of the checked domain.
589    inherited: bool,
590}
591
592/// The outcome of looking for the DMARC record set that applies.
593#[derive(Debug)]
594enum DmarcDiscovery {
595    Found(DmarcSource),
596    /// No DMARC record at the domain or any name up to its organizational
597    /// domain.
598    NotFound,
599    /// A lookup on the way failed, so a record may exist unseen.
600    LookupFailed {
601        name: String,
602        reason: String,
603    },
604}
605
606fn absent_dmarc() -> DmarcPolicy {
607    DmarcPolicy {
608        present: false,
609        record: None,
610        policy: None,
611        subdomain_policy: None,
612        effective_policy: None,
613        aggregate_reports: Vec::new(),
614        percent: None,
615        verdict: PostureVerdict::Absent,
616    }
617}
618
619/// Assembles the DMARC policy for `domain` from the discovered record set.
620fn build_dmarc(domain: &str, discovery: &DmarcDiscovery) -> Assessed<DmarcPolicy> {
621    let spoofable =
622        || vec!["No DMARC record — the domain is trivially spoofable in phishing.".to_string()];
623    let source = match discovery {
624        DmarcDiscovery::Found(source) => source,
625        DmarcDiscovery::NotFound => {
626            return Assessed {
627                policy: absent_dmarc(),
628                notes: spoofable(),
629            }
630        }
631        DmarcDiscovery::LookupFailed { name, reason } => {
632            return Assessed {
633                policy: DmarcPolicy {
634                    verdict: PostureVerdict::Unknown,
635                    ..absent_dmarc()
636                },
637                notes: vec![format!(
638                    "DMARC lookup at {name} failed ({reason}) — DMARC posture is unknown, \
639                     not absent."
640                )],
641            }
642        }
643    };
644    let record = match select_dmarc(&source.txts) {
645        Selection::None => {
646            return Assessed {
647                policy: absent_dmarc(),
648                notes: spoofable(),
649            };
650        }
651        Selection::Multiple(count) => {
652            return Assessed {
653                policy: DmarcPolicy {
654                    present: true,
655                    ..absent_dmarc()
656                },
657                notes: vec![format!(
658                    "{count} DMARC records at _dmarc.{} — receivers do not apply DMARC at all \
659                     when more than one is published (RFC 7489 §6.6.3). Publish exactly one.",
660                    source.at
661                )],
662            };
663        }
664        Selection::One(record) => record,
665    };
666
667    let tags = parse_tag_value(record);
668    let policy = tags.get("p").cloned();
669    let subdomain_policy = tags.get("sp").cloned();
670    let percent: Option<u8> = tags.get("pct").and_then(|p| p.parse().ok());
671    let aggregate_reports = tags
672        .get("rua")
673        .map(|rua| {
674            rua.split(',')
675                .map(|s| s.trim().to_string())
676                .filter(|s| !s.is_empty())
677                .collect()
678        })
679        .unwrap_or_default();
680
681    // A subdomain governed by its organizational domain's record gets that
682    // record's `sp=` policy, falling back to `p=` (RFC 7489 §6.6.3 / §6.3).
683    let (label, effective) = match (&subdomain_policy, source.inherited) {
684        (Some(sp), true) => ("sp", Some(sp.as_str())),
685        _ => ("p", policy.as_deref()),
686    };
687    let base = dmarc_verdict(effective);
688    let partial_pct = percent.filter(|pct| *pct < 100);
689    let verdict = if partial_pct.is_some() {
690        downgrade(base)
691    } else {
692        base
693    };
694
695    let mut notes = Vec::new();
696    if source.inherited {
697        notes.push(format!(
698            "No DMARC record at _dmarc.{domain} — it inherits the organizational record at \
699             _dmarc.{} (RFC 7489 §6.6.3), whose {label}= policy applies.",
700            source.at
701        ));
702    }
703    let effective_lc = effective.map(str::to_ascii_lowercase);
704    let effective_policy = effective.map(str::to_string);
705    match (base, effective_lc.as_deref()) {
706        (PostureVerdict::Moderate, _) => notes.push(format!(
707            "DMARC policy is {label}=quarantine — spoofed mail is filtered but not rejected."
708        )),
709        (PostureVerdict::Weak, Some("none")) => notes.push(format!(
710            "DMARC policy is {label}=none (monitoring only) — receivers will not reject spoofed mail."
711        )),
712        (PostureVerdict::Weak, _) => notes.push(format!(
713            "DMARC record has no valid {label}= policy — receivers treat it as monitoring only (p=none)."
714        )),
715        _ => {}
716    }
717    if let (Some(pct), Some(value)) = (partial_pct, effective_lc.as_deref()) {
718        if base != PostureVerdict::Weak {
719            notes.push(format!(
720                "DMARC pct={pct} — the {label}={value} policy is applied to only {pct}% of \
721                 failing mail (the rest gets the next-weaker treatment), so enforcement is partial."
722            ));
723        }
724    }
725
726    Assessed {
727        policy: DmarcPolicy {
728            present: true,
729            verdict,
730            subdomain_policy,
731            effective_policy,
732            percent,
733            aggregate_reports,
734            policy,
735            record: Some(record.to_string()),
736        },
737        notes,
738    }
739}
740
741/// Test-only: the DMARC policy [`build_dmarc`] assembles for `domain` from
742/// the `txts` found at `_dmarc.<at>` (inherited when `at` is not `domain`).
743#[cfg(test)]
744pub(crate) fn dmarc_from_records(domain: &str, at: &str, txts: &[&str]) -> DmarcPolicy {
745    let source = DmarcSource {
746        txts: txts.iter().map(|t| t.to_string()).collect(),
747        at: at.to_string(),
748        inherited: at != domain,
749    };
750    build_dmarc(domain, &DmarcDiscovery::Found(source)).policy
751}
752
753fn build_mta_sts(txt: &TxtLookup) -> MtaStsPolicy {
754    let absent = |verdict| MtaStsPolicy {
755        present: false,
756        record: None,
757        id: None,
758        verdict,
759    };
760    let Ok(txt) = txt else {
761        return absent(PostureVerdict::Unknown);
762    };
763    match txt.iter().find(|t| has_version_tag(t, "v=STSv1", true)) {
764        Some(record) => {
765            let tags = parse_tag_value(record);
766            MtaStsPolicy {
767                present: true,
768                id: tags.get("id").cloned(),
769                record: Some(record.clone()),
770                verdict: PostureVerdict::Present,
771            }
772        }
773        None => absent(PostureVerdict::Absent),
774    }
775}
776
777fn build_bimi(txt: &TxtLookup) -> BimiPolicy {
778    let absent = |verdict| BimiPolicy {
779        present: false,
780        record: None,
781        logo_url: None,
782        authority_url: None,
783        verdict,
784    };
785    let Ok(txt) = txt else {
786        return absent(PostureVerdict::Unknown);
787    };
788    match txt.iter().find(|t| has_version_tag(t, "v=BIMI1", true)) {
789        Some(record) => {
790            let tags = parse_tag_value(record);
791            BimiPolicy {
792                present: true,
793                logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
794                authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
795                record: Some(record.clone()),
796                verdict: PostureVerdict::Present,
797            }
798        }
799        None => absent(PostureVerdict::Absent),
800    }
801}
802
803/// The TLSA record sets DANE looked at, each with its owner scope, and
804/// whether any lookup on the way (MX included) failed.
805#[derive(Debug, Default)]
806struct TlsaSets {
807    sets: Vec<(String, Vec<crate::dns::DnsRecord>)>,
808    lookup_failed: bool,
809}
810
811/// Assembles the DANE policy from the TLSA sets. Records found are
812/// `Present` even when another lookup failed; with none found, a failed
813/// lookup makes the state `Unknown` rather than `Absent`.
814fn build_dane(tlsa: &TlsaSets) -> DanePolicy {
815    let mut records = Vec::new();
816    for (scope, set) in &tlsa.sets {
817        for r in set {
818            if let RecordData::TLSA {
819                cert_usage,
820                selector,
821                matching,
822                cert_data,
823            } = &r.data
824            {
825                records.push(TlsaRecord {
826                    scope: scope.clone(),
827                    cert_usage: *cert_usage,
828                    selector: *selector,
829                    matching: *matching,
830                    cert_data: cert_data.clone(),
831                });
832            }
833        }
834    }
835    let verdict = if !records.is_empty() {
836        PostureVerdict::Present
837    } else if tlsa.lookup_failed {
838        PostureVerdict::Unknown
839    } else {
840        PostureVerdict::Absent
841    };
842    DanePolicy {
843        present: !records.is_empty(),
844        verdict,
845        records,
846    }
847}
848
849/// Orders the advisory notes: DMARC, SPF, then the transport mechanisms.
850fn build_notes(
851    spf: &Assessed<SpfPolicy>,
852    dmarc: &Assessed<DmarcPolicy>,
853    mta_sts: &MtaStsPolicy,
854    bimi: &BimiPolicy,
855    dane: &DanePolicy,
856) -> Vec<String> {
857    let mut notes = dmarc.notes.clone();
858    notes.extend(spf.notes.iter().cloned());
859    let mta_sts_note = match mta_sts.verdict {
860        PostureVerdict::Absent => {
861            Some("MTA-STS is not configured — SMTP is vulnerable to downgrade.")
862        }
863        PostureVerdict::Unknown => Some("MTA-STS lookup failed — its state is unknown."),
864        _ => None,
865    };
866    let bimi_note = (bimi.verdict == PostureVerdict::Unknown)
867        .then_some("BIMI lookup failed — its state is unknown.");
868    let dane_note = match dane.verdict {
869        PostureVerdict::Absent => {
870            Some("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.")
871        }
872        PostureVerdict::Unknown => Some(
873            "DANE (TLSA/MX) lookup failed and no TLSA records were found — DANE state is unknown.",
874        ),
875        _ => None,
876    };
877    notes.extend(
878        [mta_sts_note, bimi_note, dane_note]
879            .into_iter()
880            .flatten()
881            .map(str::to_string),
882    );
883    notes
884}
885
886// --- Network steps (each bounded; DNS only) ------------------------------
887
888/// The resolver plus the caller's nameserver (`None` = the default
889/// upstream), so every posture query goes to the same server.
890#[derive(Clone, Copy)]
891struct Dns<'a> {
892    resolver: &'a DnsResolver,
893    nameserver: Option<&'a str>,
894}
895
896impl Dns<'_> {
897    async fn resolve(
898        &self,
899        name: &str,
900        record_type: RecordType,
901    ) -> Result<Vec<crate::dns::DnsRecord>> {
902        self.resolver
903            .resolve(name, record_type, self.nameserver)
904            .await
905    }
906
907    async fn txt(&self, name: &str) -> TxtLookup {
908        self.resolve(name, RecordType::TXT)
909            .await
910            .map(|records| txt_strings(&records))
911            .map_err(|e| e.sanitized_message())
912    }
913
914    /// The one SPF record at `target`, or `None` when it cannot be read
915    /// (macros, an invalid name, a failed lookup, none or several records).
916    async fn spf_record(&self, target: &str) -> Option<String> {
917        if target.contains('%') {
918            return None;
919        }
920        let name = normalize_host(target).ok()?;
921        match select_spf(&self.txt(&name).await.ok()?) {
922            Selection::One(record) => Some(record.to_string()),
923            _ => None,
924        }
925    }
926}
927
928/// Follows an SPF `redirect=` chain; each hop counts toward the
929/// [`MAX_SPF_LOOKUPS`] limit, so a longer chain is a permerror.
930async fn follow_spf_redirect(dns: Dns<'_>, first_target: String) -> RedirectOutcome {
931    let mut target = first_target;
932    for _ in 0..MAX_SPF_LOOKUPS {
933        if target.contains('%') {
934            return RedirectOutcome::Failed {
935                target,
936                reason: "it uses SPF macros, which seer does not expand".to_string(),
937            };
938        }
939        let Ok(name) = normalize_host(&target) else {
940            return RedirectOutcome::Failed {
941                target,
942                reason: "not a valid domain name — permerror".to_string(),
943            };
944        };
945        let txts = match dns.txt(&name).await {
946            Ok(txts) => txts,
947            Err(reason) => {
948                return RedirectOutcome::LookupFailed {
949                    target: name,
950                    reason,
951                }
952            }
953        };
954        match select_spf(&txts) {
955            Selection::None => {
956                return RedirectOutcome::Failed {
957                    target: name,
958                    reason: "it publishes no SPF record — permerror".to_string(),
959                }
960            }
961            Selection::Multiple(count) => {
962                return RedirectOutcome::Failed {
963                    target: name,
964                    reason: format!("it publishes {count} SPF records — permerror"),
965                }
966            }
967            Selection::One(record) => match spf_terminal(record) {
968                SpfTerminal::All(qualifier) => {
969                    return RedirectOutcome::Resolved {
970                        target: name,
971                        qualifier: Some(qualifier),
972                    }
973                }
974                SpfTerminal::Neither => {
975                    return RedirectOutcome::Resolved {
976                        target: name,
977                        qualifier: None,
978                    }
979                }
980                SpfTerminal::Redirect(next) => target = next,
981            },
982        }
983    }
984    RedirectOutcome::Failed {
985        target,
986        reason: format!("the redirect chain exceeds the {MAX_SPF_LOOKUPS} DNS-lookup limit"),
987    }
988}
989
990/// Counts the DNS-querying terms evaluating `record` needs (RFC 7208
991/// §4.6.4), following `include:` and `redirect=` into their records. Stops
992/// as soon as the count passes [`MAX_SPF_LOOKUPS`], and fetches at most that
993/// many records; a target that cannot be read adds nothing beyond its own
994/// term (the count is then a lower bound).
995///
996/// The targets of one level are fetched concurrently (at most
997/// [`MAX_SPF_LOOKUPS`] in flight), so N includes cost one round trip, not
998/// N. Every target was counted as a term when it was found, so a record
999/// within the limit fetches each of its targets either way, and the verdict
1000/// (count above the limit or not) is the one a target-by-target walk gives.
1001async fn count_spf_lookups(dns: Dns<'_>, record: &str) -> usize {
1002    let (mut count, mut level) = spf_lookup_terms(record);
1003    let mut fetched = 0;
1004    while !level.is_empty() && count <= MAX_SPF_LOOKUPS && fetched < MAX_SPF_LOOKUPS {
1005        level.truncate(MAX_SPF_LOOKUPS - fetched);
1006        fetched += level.len();
1007        let records =
1008            futures::future::join_all(level.iter().map(|target| dns.spf_record(target))).await;
1009        level = Vec::new();
1010        for included in records.into_iter().flatten() {
1011            let (terms, targets) = spf_lookup_terms(&included);
1012            count += terms;
1013            level.extend(targets);
1014        }
1015    }
1016    count
1017}
1018
1019/// SPF for the apex TXT lookup: follows `redirect=` when the record has no
1020/// `all` term (RFC 7208 §6.1: the redirected record then governs — grading
1021/// such a record on its own as "no -all/~all" called well-run delegated
1022/// setups spoofable), and counts its DNS lookups against the §4.6.4 limit.
1023async fn assess_spf(dns: Dns<'_>, apex_txt: &TxtLookup) -> Assessed<SpfPolicy> {
1024    let apex_txt = match apex_txt {
1025        Ok(txts) => txts,
1026        Err(reason) => return unknown_spf(reason),
1027    };
1028    let mut follow_up = SpfFollowUp::default();
1029    if let Selection::One(record) = select_spf(apex_txt) {
1030        let redirect = async {
1031            match spf_terminal(record) {
1032                SpfTerminal::Redirect(target) => Some(follow_spf_redirect(dns, target).await),
1033                _ => None,
1034            }
1035        };
1036        (follow_up.redirect, follow_up.lookups) =
1037            tokio::join!(redirect, count_spf_lookups(dns, record));
1038    }
1039    build_spf(apex_txt, &follow_up)
1040}
1041
1042/// Finds the DMARC record set that applies to `domain`: its own
1043/// `_dmarc.<domain>`, or — when that has no DMARC record — the nearest parent
1044/// that has one, walking one label at a time (at most [`MAX_DMARC_WALK`])
1045/// but never above the Organizational Domain (RFC 7489 §3.2: the registrable
1046/// domain under the Public Suffix List). RFC 7489 §6.6.3 falls back to the
1047/// Organizational Domain itself; the nearest publishing ancestor below it is
1048/// what the DMARCbis tree walk adds. A query error stops the walk rather than
1049/// skipping past a record we could not see.
1050async fn discover_dmarc(dns: Dns<'_>, domain: &str, own: TxtLookup) -> DmarcDiscovery {
1051    let failed = |name: String, reason: String| DmarcDiscovery::LookupFailed { name, reason };
1052    let own = match own {
1053        Ok(own) => own,
1054        Err(reason) => return failed(format!("_dmarc.{domain}"), reason),
1055    };
1056    if select_dmarc(&own) != Selection::None {
1057        return DmarcDiscovery::Found(DmarcSource {
1058            txts: own,
1059            at: domain.to_string(),
1060            inherited: false,
1061        });
1062    }
1063    // A registrable domain (or a bare suffix) has no organizational parent.
1064    let Some(org) = crate::psl::registrable_parent(domain) else {
1065        return DmarcDiscovery::NotFound;
1066    };
1067    let mut current = domain;
1068    for _ in 0..MAX_DMARC_WALK {
1069        let Some((_, parent)) = current.split_once('.') else {
1070            break;
1071        };
1072        if parent.len() < org.len() {
1073            break;
1074        }
1075        let name = format!("_dmarc.{parent}");
1076        let txts = match dns.txt(&name).await {
1077            Ok(txts) => txts,
1078            Err(reason) => return failed(name, reason),
1079        };
1080        if select_dmarc(&txts) != Selection::None {
1081            return DmarcDiscovery::Found(DmarcSource {
1082                txts,
1083                at: parent.to_string(),
1084                inherited: true,
1085            });
1086        }
1087        current = parent;
1088    }
1089    DmarcDiscovery::NotFound
1090}
1091
1092/// Classifies an MX lookup. A single `.` exchange is a null MX (RFC 7505).
1093fn mail_exchangers(mx: &Result<Vec<crate::dns::DnsRecord>>) -> MailExchangers {
1094    let Ok(records) = mx else {
1095        return MailExchangers::Unknown;
1096    };
1097    let mut hosts: Vec<(u16, String)> = records
1098        .iter()
1099        .filter_map(|r| match &r.data {
1100            RecordData::MX {
1101                preference,
1102                exchange,
1103            } => Some((
1104                *preference,
1105                exchange.trim_end_matches('.').to_ascii_lowercase(),
1106            )),
1107            _ => None,
1108        })
1109        .collect();
1110    if hosts.is_empty() {
1111        return MailExchangers::Absent;
1112    }
1113    if hosts.iter().any(|(_, h)| h.is_empty()) {
1114        return MailExchangers::Null;
1115    }
1116    hosts.sort();
1117    hosts.dedup_by(|a, b| a.1 == b.1);
1118    MailExchangers::Hosts(hosts.into_iter().map(|(_, h)| h).collect())
1119}
1120
1121/// SMTP DANE records per RFC 7672 §2.2: TLSA lives at `_25._tcp.<MX host>`,
1122/// not at the mail domain. Probes the first [`MAX_DANE_MX_HOSTS`] exchanges
1123/// (preference order) concurrently; a domain with no MX falls back to its
1124/// implicit MX, the domain itself (RFC 5321 §5.1). A null MX (RFC 7505)
1125/// accepts no mail, so there is nothing to pin. A failed MX lookup leaves
1126/// the hosts unknown: nothing is probed and the sets are marked failed.
1127async fn smtp_tlsa_records(
1128    dns: Dns<'_>,
1129    domain: &str,
1130    mx: Result<Vec<crate::dns::DnsRecord>>,
1131) -> TlsaSets {
1132    let Ok(mx) = mx else {
1133        return TlsaSets {
1134            sets: Vec::new(),
1135            lookup_failed: true,
1136        };
1137    };
1138    let mut hosts: Vec<String> = Vec::new();
1139    for record in &mx {
1140        if let RecordData::MX { exchange, .. } = &record.data {
1141            let host = exchange.trim_end_matches('.').to_ascii_lowercase();
1142            if !hosts.contains(&host) {
1143                hosts.push(host);
1144            }
1145        }
1146    }
1147    if hosts.iter().any(|h| h.is_empty()) {
1148        return TlsaSets::default();
1149    }
1150
1151    let owners: Vec<(String, String)> = if hosts.is_empty() {
1152        vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
1153    } else {
1154        hosts
1155            .into_iter()
1156            .take(MAX_DANE_MX_HOSTS)
1157            .map(|host| {
1158                let owner = format!("_25._tcp.{host}");
1159                (owner.clone(), owner)
1160            })
1161            .collect()
1162    };
1163    let results = futures::future::join_all(
1164        owners
1165            .iter()
1166            .map(|(_, owner)| Box::pin(dns.resolve(owner, RecordType::TLSA))),
1167    )
1168    .await;
1169    let mut tlsa = TlsaSets::default();
1170    for ((scope, _), result) in owners.into_iter().zip(results) {
1171        match result {
1172            Ok(set) => tlsa.sets.push((scope, set)),
1173            Err(_) => tlsa.lookup_failed = true,
1174        }
1175    }
1176    tlsa
1177}
1178
1179/// Resolves and interprets the email/DNS security posture for `domain`,
1180/// querying `nameserver` (a nameserver spec as for
1181/// [`DnsResolver::resolve`]; `None` = the resolver's default upstream).
1182///
1183/// Each mechanism degrades independently: a resolver error (SERVFAIL,
1184/// timeout) for one record set yields that mechanism's
1185/// [`PostureVerdict::Unknown`] with a "lookup failed" note — never `Absent`,
1186/// which would call the domain spoofable on a transient failure — rather
1187/// than failing the whole report. Returns an error only if the domain itself
1188/// is invalid.
1189pub async fn lookup_email_posture(
1190    resolver: &DnsResolver,
1191    domain: &str,
1192    nameserver: Option<&str>,
1193) -> Result<EmailPosture> {
1194    let domain = normalize_domain(domain)?;
1195    let dns = Dns {
1196        resolver,
1197        nameserver,
1198    };
1199
1200    let dmarc_name = format!("_dmarc.{domain}");
1201    let mta_sts_name = format!("_mta-sts.{domain}");
1202    let bimi_name = format!("default._bimi.{domain}");
1203    let https_tlsa_name = format!("_443._tcp.{domain}");
1204
1205    // Resolve every independent record set concurrently. Futures are boxed so
1206    // the combined `join!` frame stays small (avoids the large-future debug
1207    // stack pressure seen in diff.rs).
1208    let (apex_txt, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
1209        Box::pin(dns.txt(&domain)),
1210        Box::pin(dns.txt(&dmarc_name)),
1211        Box::pin(dns.txt(&mta_sts_name)),
1212        Box::pin(dns.txt(&bimi_name)),
1213        Box::pin(dns.resolve(&domain, RecordType::MX)),
1214        Box::pin(dns.resolve(&https_tlsa_name, RecordType::TLSA)),
1215    );
1216
1217    let mx_state = mail_exchangers(&mx);
1218
1219    // Second round: steps that depend on a first-round answer (SPF redirect
1220    // and lookup count, DMARC inheritance, TLSA at the MX hosts), again
1221    // concurrently.
1222    let (spf, dmarc_discovery, mut tlsa) = tokio::join!(
1223        Box::pin(assess_spf(dns, &apex_txt)),
1224        Box::pin(discover_dmarc(dns, &domain, dmarc)),
1225        Box::pin(smtp_tlsa_records(dns, &domain, mx)),
1226    );
1227    match https_tlsa {
1228        Ok(set) => tlsa.sets.push(("_443._tcp".to_string(), set)),
1229        Err(_) => tlsa.lookup_failed = true,
1230    }
1231
1232    let dmarc = build_dmarc(&domain, &dmarc_discovery);
1233    let mta_sts = build_mta_sts(&mta_sts);
1234    let bimi = build_bimi(&bimi);
1235    let dane = build_dane(&tlsa);
1236    let notes = build_notes(&spf, &dmarc, &mta_sts, &bimi, &dane);
1237
1238    Ok(EmailPosture {
1239        domain,
1240        spf: spf.policy,
1241        dmarc: dmarc.policy,
1242        mta_sts,
1243        bimi,
1244        dane,
1245        mx: mx_state,
1246        notes,
1247    })
1248}
1249
1250#[cfg(test)]
1251mod tests {
1252    use super::*;
1253
1254    fn strings(values: &[&str]) -> Vec<String> {
1255        values.iter().map(|v| v.to_string()).collect()
1256    }
1257
1258    fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcDiscovery {
1259        DmarcDiscovery::Found(DmarcSource {
1260            txts: strings(txts),
1261            at: domain.to_string(),
1262            inherited: false,
1263        })
1264    }
1265
1266    #[test]
1267    fn spf_qualifier_parsing() {
1268        assert_eq!(
1269            parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
1270            Some("-".to_string())
1271        );
1272        assert_eq!(
1273            parse_spf_all_qualifier("v=spf1 mx ~all"),
1274            Some("~".to_string())
1275        );
1276        assert_eq!(
1277            parse_spf_all_qualifier("v=spf1 +all"),
1278            Some("+".to_string())
1279        );
1280        assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
1281    }
1282
1283    #[test]
1284    fn spf_verdict_bands() {
1285        assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
1286        assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
1287        assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
1288        assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
1289        assert_eq!(spf_verdict(None), PostureVerdict::Weak);
1290    }
1291
1292    #[test]
1293    fn verdict_as_str_matches_serde_label() {
1294        // Text/CSV renderers use as_str(); it must never disagree with JSON.
1295        for v in [
1296            PostureVerdict::Absent,
1297            PostureVerdict::Weak,
1298            PostureVerdict::Moderate,
1299            PostureVerdict::Strict,
1300            PostureVerdict::Present,
1301        ] {
1302            assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
1303        }
1304    }
1305
1306    #[test]
1307    fn version_tag_must_be_followed_by_a_delimiter() {
1308        // Regression: a prefix test accepted `v=spf10`.
1309        assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
1310        assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
1311        assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
1312        assert!(has_version_tag("v=spf1", "v=spf1", false));
1313        assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
1314        assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
1315        assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
1316        assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
1317        assert!(
1318            !has_version_tag("v=é", "v=spf1", false),
1319            "no panic on short multibyte"
1320        );
1321        assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
1322    }
1323
1324    #[test]
1325    fn multiple_spf_records_are_a_permerror() {
1326        // RFC 7208 §4.5: two SPF records → permerror, not "the first wins".
1327        let spf = build_spf(
1328            &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
1329            &SpfFollowUp::default(),
1330        );
1331        assert!(spf.policy.present);
1332        assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1333        assert_eq!(spf.policy.all_qualifier, None);
1334        assert!(
1335            spf.notes.iter().any(|n| n.contains("permerror")),
1336            "{:?}",
1337            spf.notes
1338        );
1339    }
1340
1341    #[test]
1342    fn multiple_dmarc_records_disable_dmarc() {
1343        // RFC 7489 §6.6.3: more than one record → DMARC is not applied.
1344        let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
1345        let dmarc = build_dmarc("x.test", &source);
1346        assert!(dmarc.policy.present);
1347        assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
1348        assert!(
1349            dmarc.notes[0].contains("2 DMARC records"),
1350            "{:?}",
1351            dmarc.notes
1352        );
1353    }
1354
1355    #[test]
1356    fn dmarc_parsing_and_verdict() {
1357        let record =
1358            "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
1359        let dmarc = build_dmarc("x.com", &own_dmarc("x.com", &[record])).policy;
1360        assert!(dmarc.present);
1361        assert_eq!(dmarc.policy.as_deref(), Some("reject"));
1362        assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
1363        assert_eq!(dmarc.percent, Some(100));
1364        assert_eq!(dmarc.aggregate_reports.len(), 2);
1365        assert_eq!(dmarc.verdict, PostureVerdict::Strict);
1366    }
1367
1368    #[test]
1369    fn dmarc_none_is_weak_and_absent_is_absent() {
1370        let weak = build_dmarc("x.test", &own_dmarc("x.test", &["v=DMARC1; p=none"]));
1371        assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
1372        assert!(weak.notes[0].contains("p=none (monitoring only)"));
1373        let absent = build_dmarc("x.test", &own_dmarc("x.test", &["v=spf1 -all"]));
1374        assert!(!absent.policy.present);
1375        assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
1376    }
1377
1378    #[test]
1379    fn dmarc_partial_pct_downgrades_one_band() {
1380        // Regression: `pct=` was ignored, so p=reject;pct=0 graded Strict.
1381        let verdict = |record: &str| {
1382            let dmarc = build_dmarc("x.test", &own_dmarc("x.test", &[record]));
1383            (dmarc.policy.verdict, dmarc.notes)
1384        };
1385        let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1386        assert_eq!(v, PostureVerdict::Moderate);
1387        assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1388        assert_eq!(
1389            verdict("v=DMARC1; p=quarantine; pct=50").0,
1390            PostureVerdict::Weak
1391        );
1392        assert_eq!(
1393            verdict("v=DMARC1; p=reject; pct=100").0,
1394            PostureVerdict::Strict
1395        );
1396        assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1397    }
1398
1399    #[test]
1400    fn inherited_dmarc_applies_sp_then_p() {
1401        let inherited = |txt: &str| {
1402            DmarcDiscovery::Found(DmarcSource {
1403                txts: strings(&[txt]),
1404                at: "org.test".to_string(),
1405                inherited: true,
1406            })
1407        };
1408        let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=none; sp=reject"));
1409        assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1410        assert_eq!(dmarc.policy.effective_policy.as_deref(), Some("reject"));
1411        assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1412        assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1413
1414        let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=quarantine"));
1415        assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1416        assert_eq!(dmarc.policy.effective_policy.as_deref(), Some("quarantine"));
1417    }
1418
1419    #[test]
1420    fn spf_permerror_is_structured() {
1421        let multiple = build_spf(
1422            &strings(&["v=spf1 -all", "v=spf1 ~all"]),
1423            &SpfFollowUp::default(),
1424        );
1425        assert_eq!(
1426            multiple.policy.permerror,
1427            Some(SpfPermerror::MultipleRecords)
1428        );
1429
1430        let heavy = build_spf(
1431            &strings(&["v=spf1 include:a.test -all"]),
1432            &SpfFollowUp {
1433                lookups: MAX_SPF_LOOKUPS + 1,
1434                ..SpfFollowUp::default()
1435            },
1436        );
1437        assert_eq!(heavy.policy.permerror, Some(SpfPermerror::TooManyLookups));
1438
1439        let fine = build_spf(&strings(&["v=spf1 -all"]), &SpfFollowUp::default());
1440        assert_eq!(fine.policy.permerror, None);
1441
1442        let redirect = |outcome| SpfFollowUp {
1443            redirect: Some(outcome),
1444            ..SpfFollowUp::default()
1445        };
1446        let failed = build_spf(
1447            &strings(&["v=spf1 redirect=_spf.example.test"]),
1448            &redirect(RedirectOutcome::Failed {
1449                target: "_spf.example.test".into(),
1450                reason: "no SPF record".into(),
1451            }),
1452        );
1453        assert_eq!(failed.policy.permerror, Some(SpfPermerror::RedirectFailed));
1454
1455        // A failed DNS lookup on the chain is unknown, not a permerror.
1456        let unknown = build_spf(
1457            &strings(&["v=spf1 redirect=_spf.example.test"]),
1458            &redirect(RedirectOutcome::LookupFailed {
1459                target: "_spf.example.test".into(),
1460                reason: "timeout".into(),
1461            }),
1462        );
1463        assert_eq!(unknown.policy.verdict, PostureVerdict::Unknown);
1464        assert_eq!(unknown.policy.permerror, None);
1465    }
1466
1467    #[test]
1468    fn spf_prefers_the_spf_record_among_txt() {
1469        // The apex TXT set carries a verification token AND the SPF record.
1470        let txts = strings(&[
1471            "google-site-verification=abc123",
1472            "v=spf1 include:_spf.google.com -all",
1473        ]);
1474        let spf = build_spf(&txts, &SpfFollowUp::default()).policy;
1475        assert!(spf.present);
1476        assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1477        assert_eq!(spf.verdict, PostureVerdict::Strict);
1478    }
1479
1480    #[test]
1481    fn spf_redirect_parsing_prefers_all() {
1482        assert_eq!(
1483            spf_terminal("v=spf1 redirect=_spf.x.test"),
1484            SpfTerminal::Redirect("_spf.x.test".to_string())
1485        );
1486        assert_eq!(
1487            spf_terminal("v=spf1 Redirect=_spf.x.test"),
1488            SpfTerminal::Redirect("_spf.x.test".to_string())
1489        );
1490        // RFC 7208 §6.1: redirect is ignored when an `all` is present.
1491        assert_eq!(
1492            spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1493            SpfTerminal::All("~".to_string())
1494        );
1495        assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1496    }
1497
1498    #[test]
1499    fn bimi_and_mta_sts_parse_tags() {
1500        let bimi = build_bimi(&Ok(vec![
1501            "v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into(),
1502        ]));
1503        assert!(bimi.present);
1504        assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1505        assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1506
1507        let mta = build_mta_sts(&Ok(vec!["v=STSv1; id=20240101T000000".into()]));
1508        assert!(mta.present);
1509        assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1510        assert_eq!(mta.verdict, PostureVerdict::Present);
1511    }
1512
1513    #[test]
1514    fn notes_flag_a_spoofable_domain() {
1515        // No DMARC, no SPF, no MTA-STS, no DANE → several advisory notes.
1516        let spf = build_spf(&[], &SpfFollowUp::default());
1517        let dmarc = build_dmarc("x.test", &DmarcDiscovery::NotFound);
1518        let mta = build_mta_sts(&Ok(Vec::new()));
1519        let dane = build_dane(&TlsaSets::default());
1520        let notes = build_notes(&spf, &dmarc, &mta, &build_bimi(&Ok(Vec::new())), &dane);
1521        assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1522        assert!(notes.iter().any(|n| n.contains("No SPF")));
1523        assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1524        assert!(notes.iter().any(|n| n.contains("DANE")));
1525    }
1526
1527    // --- End to end against the scripted mock DNS fixture ----------------
1528
1529    use hickory_resolver::proto::op::ResponseCode;
1530    use hickory_resolver::proto::rr::rdata as wire;
1531    use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1532
1533    use crate::dns::test_support::{
1534        mock_dns_resolver, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1535    };
1536
1537    fn txt(value: &str) -> MockReply {
1538        MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1539    }
1540
1541    fn mx(preference: u16, exchange: &str) -> RData {
1542        RData::MX(wire::MX::new(
1543            preference,
1544            Name::from_ascii(exchange).unwrap(),
1545        ))
1546    }
1547
1548    fn tlsa() -> MockReply {
1549        MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1550            wire::tlsa::CertUsage::from(3),
1551            wire::tlsa::Selector::from(1),
1552            wire::tlsa::Matching::from(1),
1553            vec![0xAB, 0xCD],
1554        ))])
1555    }
1556
1557    /// Regression: SMTP DANE was looked up at `_25._tcp.<domain>`, but
1558    /// RFC 7672 publishes it at `_25._tcp.<MX host>` — a DANE-protected
1559    /// mail domain read as "no DANE".
1560    #[tokio::test]
1561    async fn dane_smtp_is_read_at_the_mx_hosts() {
1562        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1563            ("mail.test", WireType::MX) => {
1564                MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1565            }
1566            ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1567            _ => MockReply::NxDomain,
1568        })
1569        .await;
1570        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test", None)
1571            .await
1572            .unwrap();
1573        assert!(posture.dane.present, "{:?}", posture.dane);
1574        assert_eq!(posture.dane.records.len(), 1);
1575        assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1576        assert_eq!(
1577            posture.mx,
1578            MailExchangers::Hosts(vec!["mx1.mail.test".into(), "mx2.mail.test".into()])
1579        );
1580    }
1581
1582    /// A domain with no MX is its own (implicit) MX: TLSA at the domain.
1583    #[tokio::test]
1584    async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1585        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1586            ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1587            _ => MockReply::NoData,
1588        })
1589        .await;
1590        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test", None)
1591            .await
1592            .unwrap();
1593        assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1594        assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1595        assert_eq!(posture.mx, MailExchangers::Absent);
1596    }
1597
1598    #[tokio::test]
1599    async fn null_mx_and_failed_mx_are_distinguished() {
1600        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1601            ("null.test", WireType::MX) => MockReply::Answer(vec![mx(0, ".")]),
1602            ("broken.test", WireType::MX) => MockReply::Rcode(ResponseCode::ServFail),
1603            _ => MockReply::NoData,
1604        })
1605        .await;
1606        let resolver = mock_dns_resolver_default(port);
1607        let null = lookup_email_posture(&resolver, "null.test", None)
1608            .await
1609            .unwrap();
1610        assert_eq!(null.mx, MailExchangers::Null);
1611        let broken = lookup_email_posture(&resolver, "broken.test", None)
1612            .await
1613            .unwrap();
1614        assert_eq!(broken.mx, MailExchangers::Unknown);
1615    }
1616
1617    /// Regression: only `_dmarc.<domain>` was queried, so a subdomain
1618    /// covered by its organizational domain's record read as "No DMARC".
1619    #[tokio::test]
1620    async fn dmarc_is_inherited_from_a_parent() {
1621        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1622            ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1623            _ => MockReply::NxDomain,
1624        })
1625        .await;
1626        let posture =
1627            lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test", None)
1628                .await
1629                .unwrap();
1630        assert!(posture.dmarc.present);
1631        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1632        assert!(
1633            posture
1634                .notes
1635                .iter()
1636                .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1637            "{:?}",
1638            posture.notes
1639        );
1640    }
1641
1642    /// Regression: `v=spf1 redirect=…` (no `all`) was graded Weak /
1643    /// "spoofable"; per RFC 7208 §6.1 the redirected record governs. Two
1644    /// hops are followed.
1645    #[tokio::test]
1646    async fn spf_redirect_is_followed() {
1647        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1648            ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1649            ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1650            ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1651            ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1652            _ => MockReply::NxDomain,
1653        })
1654        .await;
1655        let resolver = mock_dns_resolver_default(port);
1656
1657        let posture = lookup_email_posture(&resolver, "brand.test", None)
1658            .await
1659            .unwrap();
1660        assert_eq!(
1661            posture.spf.verdict,
1662            PostureVerdict::Strict,
1663            "{:?}",
1664            posture.notes
1665        );
1666        assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1667        assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1668
1669        // A redirect to a name with no SPF record is a permerror.
1670        let posture = lookup_email_posture(&resolver, "broken.test", None)
1671            .await
1672            .unwrap();
1673        assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1674        assert!(
1675            posture.notes.iter().any(|n| n.contains("permerror")),
1676            "{:?}",
1677            posture.notes
1678        );
1679    }
1680
1681    #[test]
1682    fn spf_lookup_terms_count_the_querying_mechanisms() {
1683        // include, a, mx, ptr, exists count (with any qualifier); ip4/all don't.
1684        let (count, targets) = spf_lookup_terms(
1685            "v=spf1 include:_spf.a.test ~include:_spf.b.test a a:x.test/24 MX:y.test \
1686             ptr exists:%{i}.z.test ip4:192.0.2.1 -all",
1687        );
1688        assert_eq!(count, 7);
1689        assert_eq!(targets, vec!["_spf.a.test", "_spf.b.test"]);
1690        // redirect= counts only when there is no `all`.
1691        assert_eq!(
1692            spf_lookup_terms("v=spf1 redirect=_spf.c.test"),
1693            (1, vec!["_spf.c.test".to_string()])
1694        );
1695        assert_eq!(spf_lookup_terms("v=spf1 redirect=_spf.c.test -all").0, 0);
1696    }
1697
1698    #[test]
1699    fn spf_over_the_lookup_limit_is_a_permerror() {
1700        let record = strings(&["v=spf1 include:a.test -all"]);
1701        let at_limit = SpfFollowUp {
1702            redirect: None,
1703            lookups: MAX_SPF_LOOKUPS,
1704        };
1705        assert_eq!(
1706            build_spf(&record, &at_limit).policy.verdict,
1707            PostureVerdict::Strict
1708        );
1709        let over = SpfFollowUp {
1710            redirect: None,
1711            lookups: MAX_SPF_LOOKUPS + 1,
1712        };
1713        let spf = build_spf(&record, &over);
1714        assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1715        assert!(
1716            spf.notes.iter().any(|n| n.contains("§4.6.4")),
1717            "{:?}",
1718            spf.notes
1719        );
1720    }
1721
1722    /// RFC 7208 §4.6.4: the 10-lookup limit counts through includes. A
1723    /// `-all` record whose includes need 11 lookups is a permerror, not
1724    /// Strict.
1725    #[tokio::test]
1726    async fn spf_lookups_are_counted_through_includes() {
1727        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1728            ("heavy.test", WireType::TXT) => {
1729                txt("v=spf1 include:_a.heavy.test include:_b.heavy.test -all")
1730            }
1731            ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1732            ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1733            _ => MockReply::NxDomain,
1734        })
1735        .await;
1736        let resolver = mock_dns_resolver_default(port);
1737
1738        // 2 includes + 5 terms in _a + 3 in _b = 10: at the limit, still valid.
1739        let posture = lookup_email_posture(&resolver, "heavy.test", None)
1740            .await
1741            .unwrap();
1742        assert_eq!(
1743            posture.spf.verdict,
1744            PostureVerdict::Strict,
1745            "{:?}",
1746            posture.notes
1747        );
1748
1749        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1750            ("heavy.test", WireType::TXT) => {
1751                txt("v=spf1 include:_a.heavy.test include:_b.heavy.test a -all")
1752            }
1753            ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1754            ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1755            _ => MockReply::NxDomain,
1756        })
1757        .await;
1758        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "heavy.test", None)
1759            .await
1760            .unwrap();
1761        assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1762        assert!(
1763            posture.notes.iter().any(|n| n.contains("§4.6.4")),
1764            "{:?}",
1765            posture.notes
1766        );
1767    }
1768
1769    /// Regression: `include:` targets were fetched one at a time, so N
1770    /// includes cost N round trips. Each level is now fetched at once: every
1771    /// first-level target is asked before any second-level one (the
1772    /// one-at-a-time walk went depth-first), and the count is unchanged.
1773    #[tokio::test]
1774    async fn spf_includes_are_fetched_a_level_at_a_time() {
1775        use std::sync::{Arc, Mutex};
1776        let asked: Arc<Mutex<Vec<String>>> = Arc::default();
1777        let log = asked.clone();
1778        let port = spawn_mock_dns_fn(move |qname, qtype| {
1779            log.lock().unwrap().push(qname.to_string());
1780            match (qname, qtype) {
1781                ("_a.wide.test", WireType::TXT) => txt("v=spf1 include:_a2.wide.test ~all"),
1782                ("_b.wide.test", WireType::TXT) => txt("v=spf1 include:_b2.wide.test ~all"),
1783                ("_c.wide.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.1 ~all"),
1784                (_, WireType::TXT) => txt("v=spf1 a ~all"),
1785                _ => MockReply::NxDomain,
1786            }
1787        })
1788        .await;
1789        let resolver = mock_dns_resolver(port);
1790        let dns = Dns {
1791            resolver: &resolver,
1792            nameserver: Some("127.0.0.1"),
1793        };
1794        let record = "v=spf1 include:_a.wide.test include:_b.wide.test include:_c.wide.test -all";
1795
1796        // 3 includes, 2 nested includes, and an `a` in each nested record.
1797        assert_eq!(count_spf_lookups(dns, record).await, 7);
1798        let asked = asked.lock().unwrap();
1799        let position = |name: &str| asked.iter().position(|q| q == name).unwrap();
1800        let last_first = ["_a.wide.test", "_b.wide.test", "_c.wide.test"]
1801            .map(position)
1802            .into_iter()
1803            .max()
1804            .unwrap();
1805        let first_second = ["_a2.wide.test", "_b2.wide.test"]
1806            .map(position)
1807            .into_iter()
1808            .min()
1809            .unwrap();
1810        assert!(last_first < first_second, "{asked:?}");
1811    }
1812
1813    /// Regression: a SERVFAIL on `_dmarc` or the apex TXT read as "No
1814    /// DMARC/SPF — trivially spoofable". A failed lookup is Unknown.
1815    #[tokio::test]
1816    async fn failed_lookups_are_unknown_not_absent() {
1817        let port = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::ServFail)).await;
1818        let posture = lookup_email_posture(&mock_dns_resolver_default(port), "down.test", None)
1819            .await
1820            .unwrap();
1821        assert_eq!(posture.spf.verdict, PostureVerdict::Unknown);
1822        assert_eq!(posture.dmarc.verdict, PostureVerdict::Unknown);
1823        assert_eq!(posture.mta_sts.verdict, PostureVerdict::Unknown);
1824        assert_eq!(posture.bimi.verdict, PostureVerdict::Unknown);
1825        assert_eq!(posture.dane.verdict, PostureVerdict::Unknown);
1826        assert!(
1827            !posture.notes.iter().any(|n| n.contains("spoofable")
1828                || n.contains("No SPF")
1829                || n.contains("not configured")
1830                || n.contains("No DANE")),
1831            "{:?}",
1832            posture.notes
1833        );
1834        assert!(posture
1835            .notes
1836            .iter()
1837            .any(|n| n.contains("SPF lookup failed")));
1838        assert!(posture.notes.iter().any(|n| n.contains("DMARC lookup")));
1839    }
1840
1841    /// RFC 7489 §3.2: the DMARC fallback stops at the Organizational Domain
1842    /// (the registrable domain), never querying `_dmarc.co.uk`.
1843    #[tokio::test]
1844    async fn dmarc_walk_stops_at_the_organizational_domain() {
1845        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1846            ("_dmarc.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1847            _ => MockReply::NxDomain,
1848        })
1849        .await;
1850        let posture =
1851            lookup_email_posture(&mock_dns_resolver_default(port), "mail.example.co.uk", None)
1852                .await
1853                .unwrap();
1854        assert_eq!(posture.dmarc.verdict, PostureVerdict::Absent);
1855
1856        // The organizational domain itself is still the fallback.
1857        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1858            ("_dmarc.example.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1859            _ => MockReply::NxDomain,
1860        })
1861        .await;
1862        let posture = lookup_email_posture(
1863            &mock_dns_resolver_default(port),
1864            "a.mail.example.co.uk",
1865            None,
1866        )
1867        .await
1868        .unwrap();
1869        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1870    }
1871
1872    /// The caller's nameserver is used for every posture query (it used to
1873    /// hardcode the default upstream, ignoring `config.nameserver`).
1874    #[tokio::test]
1875    async fn posture_queries_the_given_nameserver() {
1876        let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1877            ("ns.test", WireType::TXT) => txt("v=spf1 -all"),
1878            ("_dmarc.ns.test", WireType::TXT) => txt("v=DMARC1; p=reject"),
1879            _ => MockReply::NxDomain,
1880        })
1881        .await;
1882        // No default-nameserver seam: only the explicit spec reaches the mock.
1883        let posture = lookup_email_posture(&mock_dns_resolver(port), "ns.test", Some("127.0.0.1"))
1884            .await
1885            .unwrap();
1886        assert_eq!(posture.spf.verdict, PostureVerdict::Strict);
1887        assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1888    }
1889}