1use std::collections::HashMap;
16
17use serde::{Deserialize, Serialize};
18
19use crate::dns::{DnsResolver, RecordData, RecordType};
20use crate::error::Result;
21use crate::validation::{normalize_domain, normalize_host};
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
27#[serde(rename_all = "kebab-case")]
28pub enum PostureVerdict {
29 Absent,
31 Weak,
33 Moderate,
35 Strict,
37 Present,
39 Unknown,
43}
44
45impl PostureVerdict {
46 pub fn as_str(self) -> &'static str {
48 match self {
49 PostureVerdict::Absent => "absent",
50 PostureVerdict::Weak => "weak",
51 PostureVerdict::Moderate => "moderate",
52 PostureVerdict::Strict => "strict",
53 PostureVerdict::Present => "present",
54 PostureVerdict::Unknown => "unknown",
55 }
56 }
57}
58
59#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SpfPolicy {
62 pub present: bool,
63 #[serde(skip_serializing_if = "Option::is_none")]
64 pub record: Option<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
68 pub all_qualifier: Option<String>,
69 pub verdict: PostureVerdict,
70}
71
72#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct DmarcPolicy {
75 pub present: bool,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub record: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub policy: Option<String>,
81 #[serde(skip_serializing_if = "Option::is_none")]
83 pub subdomain_policy: Option<String>,
84 #[serde(skip_serializing_if = "Vec::is_empty")]
86 pub aggregate_reports: Vec<String>,
87 #[serde(skip_serializing_if = "Option::is_none")]
89 pub percent: Option<u8>,
90 pub verdict: PostureVerdict,
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct MtaStsPolicy {
97 pub present: bool,
98 #[serde(skip_serializing_if = "Option::is_none")]
99 pub record: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub id: Option<String>,
103 pub verdict: PostureVerdict,
104}
105
106#[derive(Debug, Clone, Serialize, Deserialize)]
108pub struct BimiPolicy {
109 pub present: bool,
110 #[serde(skip_serializing_if = "Option::is_none")]
111 pub record: Option<String>,
112 #[serde(skip_serializing_if = "Option::is_none")]
114 pub logo_url: Option<String>,
115 #[serde(skip_serializing_if = "Option::is_none")]
117 pub authority_url: Option<String>,
118 pub verdict: PostureVerdict,
119}
120
121#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct TlsaRecord {
124 pub scope: String,
128 pub cert_usage: u8,
129 pub selector: u8,
130 pub matching: u8,
131 pub cert_data: String,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize)]
136pub struct DanePolicy {
137 pub present: bool,
138 #[serde(skip_serializing_if = "Vec::is_empty")]
139 pub records: Vec<TlsaRecord>,
140 pub verdict: PostureVerdict,
141}
142
143#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct EmailPosture {
146 pub domain: String,
147 pub spf: SpfPolicy,
148 pub dmarc: DmarcPolicy,
149 pub mta_sts: MtaStsPolicy,
150 pub bimi: BimiPolicy,
151 pub dane: DanePolicy,
152 pub notes: Vec<String>,
154}
155
156const MAX_SPF_LOOKUPS: usize = 10;
164
165const MAX_DMARC_WALK: usize = 7;
168
169const MAX_DANE_MX_HOSTS: usize = 5;
171
172fn has_version_tag(record: &str, tag: &str, allow_semicolon: bool) -> bool {
179 let record = record.trim_start();
180 let Some(head) = record.get(..tag.len()) else {
181 return false;
182 };
183 if !head.eq_ignore_ascii_case(tag) {
184 return false;
185 }
186 match record[tag.len()..].chars().next() {
187 None => true,
188 Some(c) => c.is_whitespace() || (allow_semicolon && c == ';'),
189 }
190}
191
192#[derive(Debug, PartialEq, Eq)]
194enum Selection<'a> {
195 None,
196 One(&'a str),
197 Multiple(usize),
200}
201
202fn select<'a>(records: &'a [String], tag: &str, allow_semicolon: bool) -> Selection<'a> {
203 let mut matching = records
204 .iter()
205 .filter(|r| has_version_tag(r, tag, allow_semicolon));
206 match (matching.next(), matching.count()) {
207 (None, _) => Selection::None,
208 (Some(record), 0) => Selection::One(record),
209 (Some(_), rest) => Selection::Multiple(rest + 1),
210 }
211}
212
213fn select_spf(records: &[String]) -> Selection<'_> {
214 select(records, "v=spf1", false)
215}
216
217fn select_dmarc(records: &[String]) -> Selection<'_> {
218 select(records, "v=DMARC1", true)
219}
220
221fn parse_spf_all_qualifier(record: &str) -> Option<String> {
224 for token in record.split_whitespace() {
225 let t = token.to_ascii_lowercase();
226 if t == "all" {
227 return Some("+".to_string());
228 }
229 if let Some(qual) = t.strip_suffix("all") {
230 if matches!(qual, "-" | "~" | "?" | "+") {
231 return Some(qual.to_string());
232 }
233 }
234 }
235 None
236}
237
238fn parse_spf_redirect(record: &str) -> Option<String> {
240 record.split_whitespace().find_map(|token| {
241 let (name, value) = token.split_once('=')?;
242 name.eq_ignore_ascii_case("redirect")
243 .then(|| value.to_string())
244 })
245}
246
247#[derive(Debug, PartialEq, Eq)]
249enum SpfTerminal {
250 All(String),
252 Redirect(String),
255 Neither,
257}
258
259fn spf_terminal(record: &str) -> SpfTerminal {
260 match (parse_spf_all_qualifier(record), parse_spf_redirect(record)) {
261 (Some(qualifier), _) => SpfTerminal::All(qualifier),
262 (None, Some(target)) => SpfTerminal::Redirect(target),
263 (None, None) => SpfTerminal::Neither,
264 }
265}
266
267fn spf_lookup_terms(record: &str) -> (usize, Vec<String>) {
271 let mut count = 0;
272 let mut targets = Vec::new();
273 for token in record.split_whitespace().skip(1) {
275 let term = token.trim_start_matches(['+', '-', '~', '?']);
276 if term.contains('=') {
277 continue; }
279 let name = term.split([':', '/']).next().unwrap_or(term);
280 if name.eq_ignore_ascii_case("include") {
281 count += 1;
282 targets.extend(term.split_once(':').map(|(_, t)| t.to_string()));
283 } else if ["a", "mx", "ptr", "exists"]
284 .iter()
285 .any(|m| name.eq_ignore_ascii_case(m))
286 {
287 count += 1;
288 }
289 }
290 if let SpfTerminal::Redirect(target) = spf_terminal(record) {
292 count += 1;
293 targets.push(target);
294 }
295 (count, targets)
296}
297
298fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
300 match all_qualifier {
301 Some("-") => PostureVerdict::Strict,
302 Some("~") => PostureVerdict::Moderate,
303 Some(_) | None => PostureVerdict::Weak,
306 }
307}
308
309fn parse_tag_value(record: &str) -> HashMap<String, String> {
312 record
313 .split(';')
314 .filter_map(|pair| {
315 let (k, v) = pair.split_once('=')?;
316 Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
317 })
318 .collect()
319}
320
321fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
323 match policy.map(|p| p.to_ascii_lowercase()) {
324 Some(ref p) if p == "reject" => PostureVerdict::Strict,
325 Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
326 _ => PostureVerdict::Weak,
328 }
329}
330
331fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
335 match verdict {
336 PostureVerdict::Strict => PostureVerdict::Moderate,
337 PostureVerdict::Moderate => PostureVerdict::Weak,
338 other => other,
339 }
340}
341
342fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
344 records
345 .iter()
346 .filter_map(|r| match &r.data {
347 RecordData::TXT { text, .. } => Some(text.clone()),
348 _ => None,
349 })
350 .collect()
351}
352
353#[derive(Debug)]
357struct Assessed<T> {
358 policy: T,
359 notes: Vec<String>,
360}
361
362type TxtLookup = std::result::Result<Vec<String>, String>;
364
365#[derive(Debug)]
367enum RedirectOutcome {
368 Resolved {
371 target: String,
372 qualifier: Option<String>,
373 },
374 Failed { target: String, reason: String },
377 LookupFailed { target: String, reason: String },
379}
380
381#[derive(Debug, Default)]
383struct SpfFollowUp {
384 redirect: Option<RedirectOutcome>,
386 lookups: usize,
389}
390
391fn spf_verdict_note(verdict: PostureVerdict) -> Option<String> {
392 match verdict {
393 PostureVerdict::Weak => Some(
394 "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
395 ),
396 PostureVerdict::Moderate => {
397 Some("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string())
398 }
399 _ => None,
400 }
401}
402
403fn unknown_spf(reason: &str) -> Assessed<SpfPolicy> {
405 Assessed {
406 policy: SpfPolicy {
407 present: false,
408 record: None,
409 all_qualifier: None,
410 verdict: PostureVerdict::Unknown,
411 },
412 notes: vec![format!(
413 "SPF lookup failed ({reason}) — SPF posture is unknown, not absent."
414 )],
415 }
416}
417
418fn build_spf(apex_txt: &[String], follow_up: &SpfFollowUp) -> Assessed<SpfPolicy> {
421 let record = match select_spf(apex_txt) {
422 Selection::None => {
423 return Assessed {
424 policy: SpfPolicy {
425 present: false,
426 record: None,
427 all_qualifier: None,
428 verdict: PostureVerdict::Absent,
429 },
430 notes: vec!["No SPF record — sending sources are undeclared.".to_string()],
431 };
432 }
433 Selection::Multiple(count) => {
434 return Assessed {
435 policy: SpfPolicy {
436 present: true,
437 record: None,
438 all_qualifier: None,
439 verdict: PostureVerdict::Weak,
440 },
441 notes: vec![format!(
442 "{count} SPF records are published — SPF evaluation fails with permerror \
443 (RFC 7208 §4.5), so receivers get no SPF result. Merge them into one record."
444 )],
445 };
446 }
447 Selection::One(record) => record,
448 };
449
450 let settled = |verdict: PostureVerdict, note: String| Assessed {
452 policy: SpfPolicy {
453 present: true,
454 record: Some(record.to_string()),
455 all_qualifier: None,
456 verdict,
457 },
458 notes: vec![note],
459 };
460
461 if follow_up.lookups > MAX_SPF_LOOKUPS {
462 return settled(
463 PostureVerdict::Weak,
464 format!(
465 "SPF needs more than {MAX_SPF_LOOKUPS} DNS lookups (include/a/mx/ptr/exists/\
466 redirect, counted through its includes) — evaluation fails with permerror \
467 (RFC 7208 §4.6.4), so receivers get no SPF result. Flatten or trim the record."
468 ),
469 );
470 }
471
472 let mut notes = Vec::new();
473 let all_qualifier = match (spf_terminal(record), &follow_up.redirect) {
474 (SpfTerminal::All(qualifier), _) => Some(qualifier),
475 (SpfTerminal::Redirect(_), Some(RedirectOutcome::Resolved { target, qualifier })) => {
476 notes.push(format!(
477 "SPF delegates to {target} via redirect= — the verdict reflects that record."
478 ));
479 qualifier.clone()
480 }
481 (SpfTerminal::Redirect(_), Some(RedirectOutcome::Failed { target, reason })) => {
482 return settled(
483 PostureVerdict::Weak,
484 format!(
485 "SPF redirect={target} could not be evaluated ({reason}) — receivers get \
486 no usable SPF result."
487 ),
488 );
489 }
490 (SpfTerminal::Redirect(_), Some(RedirectOutcome::LookupFailed { target, reason })) => {
491 return settled(
492 PostureVerdict::Unknown,
493 format!(
494 "SPF redirect={target} lookup failed ({reason}) — the effective SPF policy \
495 is unknown."
496 ),
497 );
498 }
499 (SpfTerminal::Redirect(_), None) | (SpfTerminal::Neither, _) => None,
500 };
501 let verdict = spf_verdict(all_qualifier.as_deref());
502 notes.extend(spf_verdict_note(verdict));
503 Assessed {
504 policy: SpfPolicy {
505 present: true,
506 verdict,
507 all_qualifier,
508 record: Some(record.to_string()),
509 },
510 notes,
511 }
512}
513
514#[derive(Debug)]
516struct DmarcSource {
517 txts: Vec<String>,
519 at: String,
521 inherited: bool,
523}
524
525#[derive(Debug)]
527enum DmarcDiscovery {
528 Found(DmarcSource),
529 NotFound,
532 LookupFailed {
534 name: String,
535 reason: String,
536 },
537}
538
539fn absent_dmarc() -> DmarcPolicy {
540 DmarcPolicy {
541 present: false,
542 record: None,
543 policy: None,
544 subdomain_policy: None,
545 aggregate_reports: Vec::new(),
546 percent: None,
547 verdict: PostureVerdict::Absent,
548 }
549}
550
551fn build_dmarc(domain: &str, discovery: &DmarcDiscovery) -> Assessed<DmarcPolicy> {
553 let spoofable =
554 || vec!["No DMARC record — the domain is trivially spoofable in phishing.".to_string()];
555 let source = match discovery {
556 DmarcDiscovery::Found(source) => source,
557 DmarcDiscovery::NotFound => {
558 return Assessed {
559 policy: absent_dmarc(),
560 notes: spoofable(),
561 }
562 }
563 DmarcDiscovery::LookupFailed { name, reason } => {
564 return Assessed {
565 policy: DmarcPolicy {
566 verdict: PostureVerdict::Unknown,
567 ..absent_dmarc()
568 },
569 notes: vec![format!(
570 "DMARC lookup at {name} failed ({reason}) — DMARC posture is unknown, \
571 not absent."
572 )],
573 }
574 }
575 };
576 let record = match select_dmarc(&source.txts) {
577 Selection::None => {
578 return Assessed {
579 policy: absent_dmarc(),
580 notes: spoofable(),
581 };
582 }
583 Selection::Multiple(count) => {
584 return Assessed {
585 policy: DmarcPolicy {
586 present: true,
587 ..absent_dmarc()
588 },
589 notes: vec![format!(
590 "{count} DMARC records at _dmarc.{} — receivers do not apply DMARC at all \
591 when more than one is published (RFC 7489 §6.6.3). Publish exactly one.",
592 source.at
593 )],
594 };
595 }
596 Selection::One(record) => record,
597 };
598
599 let tags = parse_tag_value(record);
600 let policy = tags.get("p").cloned();
601 let subdomain_policy = tags.get("sp").cloned();
602 let percent: Option<u8> = tags.get("pct").and_then(|p| p.parse().ok());
603 let aggregate_reports = tags
604 .get("rua")
605 .map(|rua| {
606 rua.split(',')
607 .map(|s| s.trim().to_string())
608 .filter(|s| !s.is_empty())
609 .collect()
610 })
611 .unwrap_or_default();
612
613 let (label, effective) = match (&subdomain_policy, source.inherited) {
616 (Some(sp), true) => ("sp", Some(sp.as_str())),
617 _ => ("p", policy.as_deref()),
618 };
619 let base = dmarc_verdict(effective);
620 let partial_pct = percent.filter(|pct| *pct < 100);
621 let verdict = if partial_pct.is_some() {
622 downgrade(base)
623 } else {
624 base
625 };
626
627 let mut notes = Vec::new();
628 if source.inherited {
629 notes.push(format!(
630 "No DMARC record at _dmarc.{domain} — it inherits the organizational record at \
631 _dmarc.{} (RFC 7489 §6.6.3), whose {label}= policy applies.",
632 source.at
633 ));
634 }
635 let effective_lc = effective.map(str::to_ascii_lowercase);
636 match (base, effective_lc.as_deref()) {
637 (PostureVerdict::Moderate, _) => notes.push(format!(
638 "DMARC policy is {label}=quarantine — spoofed mail is filtered but not rejected."
639 )),
640 (PostureVerdict::Weak, Some("none")) => notes.push(format!(
641 "DMARC policy is {label}=none (monitoring only) — receivers will not reject spoofed mail."
642 )),
643 (PostureVerdict::Weak, _) => notes.push(format!(
644 "DMARC record has no valid {label}= policy — receivers treat it as monitoring only (p=none)."
645 )),
646 _ => {}
647 }
648 if let (Some(pct), Some(value)) = (partial_pct, effective_lc.as_deref()) {
649 if base != PostureVerdict::Weak {
650 notes.push(format!(
651 "DMARC pct={pct} — the {label}={value} policy is applied to only {pct}% of \
652 failing mail (the rest gets the next-weaker treatment), so enforcement is partial."
653 ));
654 }
655 }
656
657 Assessed {
658 policy: DmarcPolicy {
659 present: true,
660 verdict,
661 subdomain_policy,
662 percent,
663 aggregate_reports,
664 policy,
665 record: Some(record.to_string()),
666 },
667 notes,
668 }
669}
670
671fn build_mta_sts(txt: &TxtLookup) -> MtaStsPolicy {
672 let absent = |verdict| MtaStsPolicy {
673 present: false,
674 record: None,
675 id: None,
676 verdict,
677 };
678 let Ok(txt) = txt else {
679 return absent(PostureVerdict::Unknown);
680 };
681 match txt.iter().find(|t| has_version_tag(t, "v=STSv1", true)) {
682 Some(record) => {
683 let tags = parse_tag_value(record);
684 MtaStsPolicy {
685 present: true,
686 id: tags.get("id").cloned(),
687 record: Some(record.clone()),
688 verdict: PostureVerdict::Present,
689 }
690 }
691 None => absent(PostureVerdict::Absent),
692 }
693}
694
695fn build_bimi(txt: &TxtLookup) -> BimiPolicy {
696 let absent = |verdict| BimiPolicy {
697 present: false,
698 record: None,
699 logo_url: None,
700 authority_url: None,
701 verdict,
702 };
703 let Ok(txt) = txt else {
704 return absent(PostureVerdict::Unknown);
705 };
706 match txt.iter().find(|t| has_version_tag(t, "v=BIMI1", true)) {
707 Some(record) => {
708 let tags = parse_tag_value(record);
709 BimiPolicy {
710 present: true,
711 logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
712 authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
713 record: Some(record.clone()),
714 verdict: PostureVerdict::Present,
715 }
716 }
717 None => absent(PostureVerdict::Absent),
718 }
719}
720
721#[derive(Debug, Default)]
724struct TlsaSets {
725 sets: Vec<(String, Vec<crate::dns::DnsRecord>)>,
726 lookup_failed: bool,
727}
728
729fn build_dane(tlsa: &TlsaSets) -> DanePolicy {
733 let mut records = Vec::new();
734 for (scope, set) in &tlsa.sets {
735 for r in set {
736 if let RecordData::TLSA {
737 cert_usage,
738 selector,
739 matching,
740 cert_data,
741 } = &r.data
742 {
743 records.push(TlsaRecord {
744 scope: scope.clone(),
745 cert_usage: *cert_usage,
746 selector: *selector,
747 matching: *matching,
748 cert_data: cert_data.clone(),
749 });
750 }
751 }
752 }
753 let verdict = if !records.is_empty() {
754 PostureVerdict::Present
755 } else if tlsa.lookup_failed {
756 PostureVerdict::Unknown
757 } else {
758 PostureVerdict::Absent
759 };
760 DanePolicy {
761 present: !records.is_empty(),
762 verdict,
763 records,
764 }
765}
766
767fn build_notes(
769 spf: &Assessed<SpfPolicy>,
770 dmarc: &Assessed<DmarcPolicy>,
771 mta_sts: &MtaStsPolicy,
772 bimi: &BimiPolicy,
773 dane: &DanePolicy,
774) -> Vec<String> {
775 let mut notes = dmarc.notes.clone();
776 notes.extend(spf.notes.iter().cloned());
777 let mta_sts_note = match mta_sts.verdict {
778 PostureVerdict::Absent => {
779 Some("MTA-STS is not configured — SMTP is vulnerable to downgrade.")
780 }
781 PostureVerdict::Unknown => Some("MTA-STS lookup failed — its state is unknown."),
782 _ => None,
783 };
784 let bimi_note = (bimi.verdict == PostureVerdict::Unknown)
785 .then_some("BIMI lookup failed — its state is unknown.");
786 let dane_note = match dane.verdict {
787 PostureVerdict::Absent => {
788 Some("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.")
789 }
790 PostureVerdict::Unknown => Some(
791 "DANE (TLSA/MX) lookup failed and no TLSA records were found — DANE state is unknown.",
792 ),
793 _ => None,
794 };
795 notes.extend(
796 [mta_sts_note, bimi_note, dane_note]
797 .into_iter()
798 .flatten()
799 .map(str::to_string),
800 );
801 notes
802}
803
804#[derive(Clone, Copy)]
809struct Dns<'a> {
810 resolver: &'a DnsResolver,
811 nameserver: Option<&'a str>,
812}
813
814impl Dns<'_> {
815 async fn resolve(
816 &self,
817 name: &str,
818 record_type: RecordType,
819 ) -> Result<Vec<crate::dns::DnsRecord>> {
820 self.resolver
821 .resolve(name, record_type, self.nameserver)
822 .await
823 }
824
825 async fn txt(&self, name: &str) -> TxtLookup {
826 self.resolve(name, RecordType::TXT)
827 .await
828 .map(|records| txt_strings(&records))
829 .map_err(|e| e.sanitized_message())
830 }
831
832 async fn spf_record(&self, target: &str) -> Option<String> {
835 if target.contains('%') {
836 return None;
837 }
838 let name = normalize_host(target).ok()?;
839 match select_spf(&self.txt(&name).await.ok()?) {
840 Selection::One(record) => Some(record.to_string()),
841 _ => None,
842 }
843 }
844}
845
846async fn follow_spf_redirect(dns: Dns<'_>, first_target: String) -> RedirectOutcome {
849 let mut target = first_target;
850 for _ in 0..MAX_SPF_LOOKUPS {
851 if target.contains('%') {
852 return RedirectOutcome::Failed {
853 target,
854 reason: "it uses SPF macros, which seer does not expand".to_string(),
855 };
856 }
857 let Ok(name) = normalize_host(&target) else {
858 return RedirectOutcome::Failed {
859 target,
860 reason: "not a valid domain name — permerror".to_string(),
861 };
862 };
863 let txts = match dns.txt(&name).await {
864 Ok(txts) => txts,
865 Err(reason) => {
866 return RedirectOutcome::LookupFailed {
867 target: name,
868 reason,
869 }
870 }
871 };
872 match select_spf(&txts) {
873 Selection::None => {
874 return RedirectOutcome::Failed {
875 target: name,
876 reason: "it publishes no SPF record — permerror".to_string(),
877 }
878 }
879 Selection::Multiple(count) => {
880 return RedirectOutcome::Failed {
881 target: name,
882 reason: format!("it publishes {count} SPF records — permerror"),
883 }
884 }
885 Selection::One(record) => match spf_terminal(record) {
886 SpfTerminal::All(qualifier) => {
887 return RedirectOutcome::Resolved {
888 target: name,
889 qualifier: Some(qualifier),
890 }
891 }
892 SpfTerminal::Neither => {
893 return RedirectOutcome::Resolved {
894 target: name,
895 qualifier: None,
896 }
897 }
898 SpfTerminal::Redirect(next) => target = next,
899 },
900 }
901 }
902 RedirectOutcome::Failed {
903 target,
904 reason: format!("the redirect chain exceeds the {MAX_SPF_LOOKUPS} DNS-lookup limit"),
905 }
906}
907
908async fn count_spf_lookups(dns: Dns<'_>, record: &str) -> usize {
914 let (mut count, mut pending) = spf_lookup_terms(record);
915 while let Some(target) = pending.pop() {
916 if count > MAX_SPF_LOOKUPS {
917 break;
918 }
919 if let Some(included) = dns.spf_record(&target).await {
920 let (terms, targets) = spf_lookup_terms(&included);
921 count += terms;
922 pending.extend(targets);
923 }
924 }
925 count
926}
927
928async fn assess_spf(dns: Dns<'_>, apex_txt: &TxtLookup) -> Assessed<SpfPolicy> {
933 let apex_txt = match apex_txt {
934 Ok(txts) => txts,
935 Err(reason) => return unknown_spf(reason),
936 };
937 let mut follow_up = SpfFollowUp::default();
938 if let Selection::One(record) = select_spf(apex_txt) {
939 let redirect = async {
940 match spf_terminal(record) {
941 SpfTerminal::Redirect(target) => Some(follow_spf_redirect(dns, target).await),
942 _ => None,
943 }
944 };
945 (follow_up.redirect, follow_up.lookups) =
946 tokio::join!(redirect, count_spf_lookups(dns, record));
947 }
948 build_spf(apex_txt, &follow_up)
949}
950
951async fn discover_dmarc(dns: Dns<'_>, domain: &str, own: TxtLookup) -> DmarcDiscovery {
960 let failed = |name: String, reason: String| DmarcDiscovery::LookupFailed { name, reason };
961 let own = match own {
962 Ok(own) => own,
963 Err(reason) => return failed(format!("_dmarc.{domain}"), reason),
964 };
965 if select_dmarc(&own) != Selection::None {
966 return DmarcDiscovery::Found(DmarcSource {
967 txts: own,
968 at: domain.to_string(),
969 inherited: false,
970 });
971 }
972 let Some(org) = crate::psl::registrable_parent(domain) else {
974 return DmarcDiscovery::NotFound;
975 };
976 let mut current = domain;
977 for _ in 0..MAX_DMARC_WALK {
978 let Some((_, parent)) = current.split_once('.') else {
979 break;
980 };
981 if parent.len() < org.len() {
982 break;
983 }
984 let name = format!("_dmarc.{parent}");
985 let txts = match dns.txt(&name).await {
986 Ok(txts) => txts,
987 Err(reason) => return failed(name, reason),
988 };
989 if select_dmarc(&txts) != Selection::None {
990 return DmarcDiscovery::Found(DmarcSource {
991 txts,
992 at: parent.to_string(),
993 inherited: true,
994 });
995 }
996 current = parent;
997 }
998 DmarcDiscovery::NotFound
999}
1000
1001async fn smtp_tlsa_records(
1008 dns: Dns<'_>,
1009 domain: &str,
1010 mx: Result<Vec<crate::dns::DnsRecord>>,
1011) -> TlsaSets {
1012 let Ok(mx) = mx else {
1013 return TlsaSets {
1014 sets: Vec::new(),
1015 lookup_failed: true,
1016 };
1017 };
1018 let mut hosts: Vec<String> = Vec::new();
1019 for record in &mx {
1020 if let RecordData::MX { exchange, .. } = &record.data {
1021 let host = exchange.trim_end_matches('.').to_ascii_lowercase();
1022 if !hosts.contains(&host) {
1023 hosts.push(host);
1024 }
1025 }
1026 }
1027 if hosts.iter().any(|h| h.is_empty()) {
1028 return TlsaSets::default();
1029 }
1030
1031 let owners: Vec<(String, String)> = if hosts.is_empty() {
1032 vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
1033 } else {
1034 hosts
1035 .into_iter()
1036 .take(MAX_DANE_MX_HOSTS)
1037 .map(|host| {
1038 let owner = format!("_25._tcp.{host}");
1039 (owner.clone(), owner)
1040 })
1041 .collect()
1042 };
1043 let results = futures::future::join_all(
1044 owners
1045 .iter()
1046 .map(|(_, owner)| Box::pin(dns.resolve(owner, RecordType::TLSA))),
1047 )
1048 .await;
1049 let mut tlsa = TlsaSets::default();
1050 for ((scope, _), result) in owners.into_iter().zip(results) {
1051 match result {
1052 Ok(set) => tlsa.sets.push((scope, set)),
1053 Err(_) => tlsa.lookup_failed = true,
1054 }
1055 }
1056 tlsa
1057}
1058
1059pub async fn lookup_email_posture(
1070 resolver: &DnsResolver,
1071 domain: &str,
1072 nameserver: Option<&str>,
1073) -> Result<EmailPosture> {
1074 let domain = normalize_domain(domain)?;
1075 let dns = Dns {
1076 resolver,
1077 nameserver,
1078 };
1079
1080 let dmarc_name = format!("_dmarc.{domain}");
1081 let mta_sts_name = format!("_mta-sts.{domain}");
1082 let bimi_name = format!("default._bimi.{domain}");
1083 let https_tlsa_name = format!("_443._tcp.{domain}");
1084
1085 let (apex_txt, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
1089 Box::pin(dns.txt(&domain)),
1090 Box::pin(dns.txt(&dmarc_name)),
1091 Box::pin(dns.txt(&mta_sts_name)),
1092 Box::pin(dns.txt(&bimi_name)),
1093 Box::pin(dns.resolve(&domain, RecordType::MX)),
1094 Box::pin(dns.resolve(&https_tlsa_name, RecordType::TLSA)),
1095 );
1096
1097 let (spf, dmarc_discovery, mut tlsa) = tokio::join!(
1101 Box::pin(assess_spf(dns, &apex_txt)),
1102 Box::pin(discover_dmarc(dns, &domain, dmarc)),
1103 Box::pin(smtp_tlsa_records(dns, &domain, mx)),
1104 );
1105 match https_tlsa {
1106 Ok(set) => tlsa.sets.push(("_443._tcp".to_string(), set)),
1107 Err(_) => tlsa.lookup_failed = true,
1108 }
1109
1110 let dmarc = build_dmarc(&domain, &dmarc_discovery);
1111 let mta_sts = build_mta_sts(&mta_sts);
1112 let bimi = build_bimi(&bimi);
1113 let dane = build_dane(&tlsa);
1114 let notes = build_notes(&spf, &dmarc, &mta_sts, &bimi, &dane);
1115
1116 Ok(EmailPosture {
1117 domain,
1118 spf: spf.policy,
1119 dmarc: dmarc.policy,
1120 mta_sts,
1121 bimi,
1122 dane,
1123 notes,
1124 })
1125}
1126
1127#[cfg(test)]
1128mod tests {
1129 use super::*;
1130
1131 fn strings(values: &[&str]) -> Vec<String> {
1132 values.iter().map(|v| v.to_string()).collect()
1133 }
1134
1135 fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcDiscovery {
1136 DmarcDiscovery::Found(DmarcSource {
1137 txts: strings(txts),
1138 at: domain.to_string(),
1139 inherited: false,
1140 })
1141 }
1142
1143 #[test]
1144 fn spf_qualifier_parsing() {
1145 assert_eq!(
1146 parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
1147 Some("-".to_string())
1148 );
1149 assert_eq!(
1150 parse_spf_all_qualifier("v=spf1 mx ~all"),
1151 Some("~".to_string())
1152 );
1153 assert_eq!(
1154 parse_spf_all_qualifier("v=spf1 +all"),
1155 Some("+".to_string())
1156 );
1157 assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
1158 }
1159
1160 #[test]
1161 fn spf_verdict_bands() {
1162 assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
1163 assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
1164 assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
1165 assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
1166 assert_eq!(spf_verdict(None), PostureVerdict::Weak);
1167 }
1168
1169 #[test]
1170 fn verdict_as_str_matches_serde_label() {
1171 for v in [
1173 PostureVerdict::Absent,
1174 PostureVerdict::Weak,
1175 PostureVerdict::Moderate,
1176 PostureVerdict::Strict,
1177 PostureVerdict::Present,
1178 ] {
1179 assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
1180 }
1181 }
1182
1183 #[test]
1184 fn version_tag_must_be_followed_by_a_delimiter() {
1185 assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
1187 assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
1188 assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
1189 assert!(has_version_tag("v=spf1", "v=spf1", false));
1190 assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
1191 assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
1192 assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
1193 assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
1194 assert!(
1195 !has_version_tag("v=é", "v=spf1", false),
1196 "no panic on short multibyte"
1197 );
1198 assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
1199 }
1200
1201 #[test]
1202 fn multiple_spf_records_are_a_permerror() {
1203 let spf = build_spf(
1205 &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
1206 &SpfFollowUp::default(),
1207 );
1208 assert!(spf.policy.present);
1209 assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1210 assert_eq!(spf.policy.all_qualifier, None);
1211 assert!(
1212 spf.notes.iter().any(|n| n.contains("permerror")),
1213 "{:?}",
1214 spf.notes
1215 );
1216 }
1217
1218 #[test]
1219 fn multiple_dmarc_records_disable_dmarc() {
1220 let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
1222 let dmarc = build_dmarc("x.test", &source);
1223 assert!(dmarc.policy.present);
1224 assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
1225 assert!(
1226 dmarc.notes[0].contains("2 DMARC records"),
1227 "{:?}",
1228 dmarc.notes
1229 );
1230 }
1231
1232 #[test]
1233 fn dmarc_parsing_and_verdict() {
1234 let record =
1235 "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
1236 let dmarc = build_dmarc("x.com", &own_dmarc("x.com", &[record])).policy;
1237 assert!(dmarc.present);
1238 assert_eq!(dmarc.policy.as_deref(), Some("reject"));
1239 assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
1240 assert_eq!(dmarc.percent, Some(100));
1241 assert_eq!(dmarc.aggregate_reports.len(), 2);
1242 assert_eq!(dmarc.verdict, PostureVerdict::Strict);
1243 }
1244
1245 #[test]
1246 fn dmarc_none_is_weak_and_absent_is_absent() {
1247 let weak = build_dmarc("x.test", &own_dmarc("x.test", &["v=DMARC1; p=none"]));
1248 assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
1249 assert!(weak.notes[0].contains("p=none (monitoring only)"));
1250 let absent = build_dmarc("x.test", &own_dmarc("x.test", &["v=spf1 -all"]));
1251 assert!(!absent.policy.present);
1252 assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
1253 }
1254
1255 #[test]
1256 fn dmarc_partial_pct_downgrades_one_band() {
1257 let verdict = |record: &str| {
1259 let dmarc = build_dmarc("x.test", &own_dmarc("x.test", &[record]));
1260 (dmarc.policy.verdict, dmarc.notes)
1261 };
1262 let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1263 assert_eq!(v, PostureVerdict::Moderate);
1264 assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1265 assert_eq!(
1266 verdict("v=DMARC1; p=quarantine; pct=50").0,
1267 PostureVerdict::Weak
1268 );
1269 assert_eq!(
1270 verdict("v=DMARC1; p=reject; pct=100").0,
1271 PostureVerdict::Strict
1272 );
1273 assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1274 }
1275
1276 #[test]
1277 fn inherited_dmarc_applies_sp_then_p() {
1278 let inherited = |txt: &str| {
1279 DmarcDiscovery::Found(DmarcSource {
1280 txts: strings(&[txt]),
1281 at: "org.test".to_string(),
1282 inherited: true,
1283 })
1284 };
1285 let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=none; sp=reject"));
1286 assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1287 assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1288 assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1289
1290 let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=quarantine"));
1291 assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1292 }
1293
1294 #[test]
1295 fn spf_prefers_the_spf_record_among_txt() {
1296 let txts = strings(&[
1298 "google-site-verification=abc123",
1299 "v=spf1 include:_spf.google.com -all",
1300 ]);
1301 let spf = build_spf(&txts, &SpfFollowUp::default()).policy;
1302 assert!(spf.present);
1303 assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1304 assert_eq!(spf.verdict, PostureVerdict::Strict);
1305 }
1306
1307 #[test]
1308 fn spf_redirect_parsing_prefers_all() {
1309 assert_eq!(
1310 spf_terminal("v=spf1 redirect=_spf.x.test"),
1311 SpfTerminal::Redirect("_spf.x.test".to_string())
1312 );
1313 assert_eq!(
1314 spf_terminal("v=spf1 Redirect=_spf.x.test"),
1315 SpfTerminal::Redirect("_spf.x.test".to_string())
1316 );
1317 assert_eq!(
1319 spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1320 SpfTerminal::All("~".to_string())
1321 );
1322 assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1323 }
1324
1325 #[test]
1326 fn bimi_and_mta_sts_parse_tags() {
1327 let bimi = build_bimi(&Ok(vec![
1328 "v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into(),
1329 ]));
1330 assert!(bimi.present);
1331 assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1332 assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1333
1334 let mta = build_mta_sts(&Ok(vec!["v=STSv1; id=20240101T000000".into()]));
1335 assert!(mta.present);
1336 assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1337 assert_eq!(mta.verdict, PostureVerdict::Present);
1338 }
1339
1340 #[test]
1341 fn notes_flag_a_spoofable_domain() {
1342 let spf = build_spf(&[], &SpfFollowUp::default());
1344 let dmarc = build_dmarc("x.test", &DmarcDiscovery::NotFound);
1345 let mta = build_mta_sts(&Ok(Vec::new()));
1346 let dane = build_dane(&TlsaSets::default());
1347 let notes = build_notes(&spf, &dmarc, &mta, &build_bimi(&Ok(Vec::new())), &dane);
1348 assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1349 assert!(notes.iter().any(|n| n.contains("No SPF")));
1350 assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1351 assert!(notes.iter().any(|n| n.contains("DANE")));
1352 }
1353
1354 use hickory_resolver::proto::rr::rdata as wire;
1357 use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1358
1359 use crate::dns::test_support::{
1360 mock_dns_resolver, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1361 };
1362
1363 fn txt(value: &str) -> MockReply {
1364 MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1365 }
1366
1367 fn mx(preference: u16, exchange: &str) -> RData {
1368 RData::MX(wire::MX::new(
1369 preference,
1370 Name::from_ascii(exchange).unwrap(),
1371 ))
1372 }
1373
1374 fn tlsa() -> MockReply {
1375 MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1376 wire::tlsa::CertUsage::from(3),
1377 wire::tlsa::Selector::from(1),
1378 wire::tlsa::Matching::from(1),
1379 vec![0xAB, 0xCD],
1380 ))])
1381 }
1382
1383 #[tokio::test]
1387 async fn dane_smtp_is_read_at_the_mx_hosts() {
1388 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1389 ("mail.test", WireType::MX) => {
1390 MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1391 }
1392 ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1393 _ => MockReply::NxDomain,
1394 })
1395 .await;
1396 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test", None)
1397 .await
1398 .unwrap();
1399 assert!(posture.dane.present, "{:?}", posture.dane);
1400 assert_eq!(posture.dane.records.len(), 1);
1401 assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1402 }
1403
1404 #[tokio::test]
1406 async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1407 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1408 ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1409 _ => MockReply::NoData,
1410 })
1411 .await;
1412 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test", None)
1413 .await
1414 .unwrap();
1415 assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1416 assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1417 }
1418
1419 #[tokio::test]
1422 async fn dmarc_is_inherited_from_a_parent() {
1423 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1424 ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1425 _ => MockReply::NxDomain,
1426 })
1427 .await;
1428 let posture =
1429 lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test", None)
1430 .await
1431 .unwrap();
1432 assert!(posture.dmarc.present);
1433 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1434 assert!(
1435 posture
1436 .notes
1437 .iter()
1438 .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1439 "{:?}",
1440 posture.notes
1441 );
1442 }
1443
1444 #[tokio::test]
1448 async fn spf_redirect_is_followed() {
1449 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1450 ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1451 ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1452 ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1453 ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1454 _ => MockReply::NxDomain,
1455 })
1456 .await;
1457 let resolver = mock_dns_resolver_default(port);
1458
1459 let posture = lookup_email_posture(&resolver, "brand.test", None)
1460 .await
1461 .unwrap();
1462 assert_eq!(
1463 posture.spf.verdict,
1464 PostureVerdict::Strict,
1465 "{:?}",
1466 posture.notes
1467 );
1468 assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1469 assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1470
1471 let posture = lookup_email_posture(&resolver, "broken.test", None)
1473 .await
1474 .unwrap();
1475 assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1476 assert!(
1477 posture.notes.iter().any(|n| n.contains("permerror")),
1478 "{:?}",
1479 posture.notes
1480 );
1481 }
1482
1483 #[test]
1484 fn spf_lookup_terms_count_the_querying_mechanisms() {
1485 let (count, targets) = spf_lookup_terms(
1487 "v=spf1 include:_spf.a.test ~include:_spf.b.test a a:x.test/24 MX:y.test \
1488 ptr exists:%{i}.z.test ip4:192.0.2.1 -all",
1489 );
1490 assert_eq!(count, 7);
1491 assert_eq!(targets, vec!["_spf.a.test", "_spf.b.test"]);
1492 assert_eq!(
1494 spf_lookup_terms("v=spf1 redirect=_spf.c.test"),
1495 (1, vec!["_spf.c.test".to_string()])
1496 );
1497 assert_eq!(spf_lookup_terms("v=spf1 redirect=_spf.c.test -all").0, 0);
1498 }
1499
1500 #[test]
1501 fn spf_over_the_lookup_limit_is_a_permerror() {
1502 let record = strings(&["v=spf1 include:a.test -all"]);
1503 let at_limit = SpfFollowUp {
1504 redirect: None,
1505 lookups: MAX_SPF_LOOKUPS,
1506 };
1507 assert_eq!(
1508 build_spf(&record, &at_limit).policy.verdict,
1509 PostureVerdict::Strict
1510 );
1511 let over = SpfFollowUp {
1512 redirect: None,
1513 lookups: MAX_SPF_LOOKUPS + 1,
1514 };
1515 let spf = build_spf(&record, &over);
1516 assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1517 assert!(
1518 spf.notes.iter().any(|n| n.contains("§4.6.4")),
1519 "{:?}",
1520 spf.notes
1521 );
1522 }
1523
1524 #[tokio::test]
1528 async fn spf_lookups_are_counted_through_includes() {
1529 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1530 ("heavy.test", WireType::TXT) => {
1531 txt("v=spf1 include:_a.heavy.test include:_b.heavy.test -all")
1532 }
1533 ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1534 ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1535 _ => MockReply::NxDomain,
1536 })
1537 .await;
1538 let resolver = mock_dns_resolver_default(port);
1539
1540 let posture = lookup_email_posture(&resolver, "heavy.test", None)
1542 .await
1543 .unwrap();
1544 assert_eq!(
1545 posture.spf.verdict,
1546 PostureVerdict::Strict,
1547 "{:?}",
1548 posture.notes
1549 );
1550
1551 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1552 ("heavy.test", WireType::TXT) => {
1553 txt("v=spf1 include:_a.heavy.test include:_b.heavy.test a -all")
1554 }
1555 ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1556 ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1557 _ => MockReply::NxDomain,
1558 })
1559 .await;
1560 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "heavy.test", None)
1561 .await
1562 .unwrap();
1563 assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1564 assert!(
1565 posture.notes.iter().any(|n| n.contains("§4.6.4")),
1566 "{:?}",
1567 posture.notes
1568 );
1569 }
1570
1571 #[tokio::test]
1574 async fn failed_lookups_are_unknown_not_absent() {
1575 let port = spawn_mock_dns_fn(|_, _| {
1576 MockReply::Rcode(hickory_resolver::proto::op::ResponseCode::ServFail)
1577 })
1578 .await;
1579 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "down.test", None)
1580 .await
1581 .unwrap();
1582 assert_eq!(posture.spf.verdict, PostureVerdict::Unknown);
1583 assert_eq!(posture.dmarc.verdict, PostureVerdict::Unknown);
1584 assert_eq!(posture.mta_sts.verdict, PostureVerdict::Unknown);
1585 assert_eq!(posture.bimi.verdict, PostureVerdict::Unknown);
1586 assert_eq!(posture.dane.verdict, PostureVerdict::Unknown);
1587 assert!(
1588 !posture.notes.iter().any(|n| n.contains("spoofable")
1589 || n.contains("No SPF")
1590 || n.contains("not configured")
1591 || n.contains("No DANE")),
1592 "{:?}",
1593 posture.notes
1594 );
1595 assert!(posture
1596 .notes
1597 .iter()
1598 .any(|n| n.contains("SPF lookup failed")));
1599 assert!(posture.notes.iter().any(|n| n.contains("DMARC lookup")));
1600 }
1601
1602 #[tokio::test]
1605 async fn dmarc_walk_stops_at_the_organizational_domain() {
1606 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1607 ("_dmarc.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1608 _ => MockReply::NxDomain,
1609 })
1610 .await;
1611 let posture =
1612 lookup_email_posture(&mock_dns_resolver_default(port), "mail.example.co.uk", None)
1613 .await
1614 .unwrap();
1615 assert_eq!(posture.dmarc.verdict, PostureVerdict::Absent);
1616
1617 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1619 ("_dmarc.example.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1620 _ => MockReply::NxDomain,
1621 })
1622 .await;
1623 let posture = lookup_email_posture(
1624 &mock_dns_resolver_default(port),
1625 "a.mail.example.co.uk",
1626 None,
1627 )
1628 .await
1629 .unwrap();
1630 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1631 }
1632
1633 #[tokio::test]
1636 async fn posture_queries_the_given_nameserver() {
1637 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1638 ("ns.test", WireType::TXT) => txt("v=spf1 -all"),
1639 ("_dmarc.ns.test", WireType::TXT) => txt("v=DMARC1; p=reject"),
1640 _ => MockReply::NxDomain,
1641 })
1642 .await;
1643 let posture = lookup_email_posture(&mock_dns_resolver(port), "ns.test", Some("127.0.0.1"))
1645 .await
1646 .unwrap();
1647 assert_eq!(posture.spf.verdict, PostureVerdict::Strict);
1648 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1649 }
1650}