1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
33#[serde(rename_all = "kebab-case")]
34pub enum PostureVerdict {
35 Absent,
37 Weak,
39 Moderate,
41 Strict,
43 Present,
45 Unknown,
49}
50
51impl PostureVerdict {
52 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#[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 #[serde(skip_serializing_if = "Option::is_none")]
74 pub all_qualifier: Option<String>,
75 pub verdict: PostureVerdict,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
78 pub permerror: Option<SpfPermerror>,
79}
80
81#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
84#[serde(rename_all = "kebab-case")]
85pub enum SpfPermerror {
86 MultipleRecords,
88 TooManyLookups,
90 RedirectFailed,
92}
93
94impl SpfPermerror {
95 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#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
107#[serde(tag = "state", content = "hosts", rename_all = "kebab-case")]
108pub enum MailExchangers {
109 Hosts(Vec<String>),
111 Null,
113 Absent,
115 #[default]
117 Unknown,
118}
119
120#[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 #[serde(skip_serializing_if = "Option::is_none")]
128 pub policy: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
131 pub subdomain_policy: Option<String>,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub effective_policy: Option<String>,
138 #[serde(skip_serializing_if = "Vec::is_empty")]
140 pub aggregate_reports: Vec<String>,
141 #[serde(skip_serializing_if = "Option::is_none")]
143 pub percent: Option<u8>,
144 pub verdict: PostureVerdict,
145}
146
147#[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 #[serde(skip_serializing_if = "Option::is_none")]
156 pub id: Option<String>,
157 pub verdict: PostureVerdict,
158}
159
160#[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 #[serde(skip_serializing_if = "Option::is_none")]
168 pub logo_url: Option<String>,
169 #[serde(skip_serializing_if = "Option::is_none")]
171 pub authority_url: Option<String>,
172 pub verdict: PostureVerdict,
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct TlsaRecord {
178 pub scope: String,
182 pub cert_usage: u8,
183 pub selector: u8,
184 pub matching: u8,
185 pub cert_data: String,
186}
187
188#[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#[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 #[serde(default)]
209 pub mx: MailExchangers,
210 pub notes: Vec<String>,
212}
213
214const MAX_SPF_LOOKUPS: usize = 10;
222
223const MAX_DMARC_WALK: usize = 7;
226
227const MAX_DANE_MX_HOSTS: usize = 5;
229
230fn 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#[derive(Debug, PartialEq, Eq)]
252enum Selection<'a> {
253 None,
254 One(&'a str),
255 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
279fn 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
296fn 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#[derive(Debug, PartialEq, Eq)]
307enum SpfTerminal {
308 All(String),
310 Redirect(String),
313 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
325fn spf_lookup_terms(record: &str) -> (usize, Vec<String>) {
329 let mut count = 0;
330 let mut targets = Vec::new();
331 for token in record.split_whitespace().skip(1) {
333 let term = token.trim_start_matches(['+', '-', '~', '?']);
334 if term.contains('=') {
335 continue; }
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 if let SpfTerminal::Redirect(target) = spf_terminal(record) {
350 count += 1;
351 targets.push(target);
352 }
353 (count, targets)
354}
355
356fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
358 match all_qualifier {
359 Some("-") => PostureVerdict::Strict,
360 Some("~") => PostureVerdict::Moderate,
361 Some(_) | None => PostureVerdict::Weak,
364 }
365}
366
367fn 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
379fn 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 _ => PostureVerdict::Weak,
386 }
387}
388
389fn downgrade(verdict: PostureVerdict) -> PostureVerdict {
393 match verdict {
394 PostureVerdict::Strict => PostureVerdict::Moderate,
395 PostureVerdict::Moderate => PostureVerdict::Weak,
396 other => other,
397 }
398}
399
400fn 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#[derive(Debug)]
415struct Assessed<T> {
416 policy: T,
417 notes: Vec<String>,
418}
419
420type TxtLookup = std::result::Result<Vec<String>, String>;
422
423#[derive(Debug)]
425enum RedirectOutcome {
426 Resolved {
429 target: String,
430 qualifier: Option<String>,
431 },
432 Failed { target: String, reason: String },
435 LookupFailed { target: String, reason: String },
437}
438
439#[derive(Debug, Default)]
441struct SpfFollowUp {
442 redirect: Option<RedirectOutcome>,
444 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
461fn 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
477fn 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 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#[derive(Debug)]
583struct DmarcSource {
584 txts: Vec<String>,
586 at: String,
588 inherited: bool,
590}
591
592#[derive(Debug)]
594enum DmarcDiscovery {
595 Found(DmarcSource),
596 NotFound,
599 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
619fn 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 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#[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#[derive(Debug, Default)]
806struct TlsaSets {
807 sets: Vec<(String, Vec<crate::dns::DnsRecord>)>,
808 lookup_failed: bool,
809}
810
811fn 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
849fn 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#[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 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
928async 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
990async fn count_spf_lookups(dns: Dns<'_>, record: &str) -> usize {
996 let (mut count, mut pending) = spf_lookup_terms(record);
997 while let Some(target) = pending.pop() {
998 if count > MAX_SPF_LOOKUPS {
999 break;
1000 }
1001 if let Some(included) = dns.spf_record(&target).await {
1002 let (terms, targets) = spf_lookup_terms(&included);
1003 count += terms;
1004 pending.extend(targets);
1005 }
1006 }
1007 count
1008}
1009
1010async fn assess_spf(dns: Dns<'_>, apex_txt: &TxtLookup) -> Assessed<SpfPolicy> {
1015 let apex_txt = match apex_txt {
1016 Ok(txts) => txts,
1017 Err(reason) => return unknown_spf(reason),
1018 };
1019 let mut follow_up = SpfFollowUp::default();
1020 if let Selection::One(record) = select_spf(apex_txt) {
1021 let redirect = async {
1022 match spf_terminal(record) {
1023 SpfTerminal::Redirect(target) => Some(follow_spf_redirect(dns, target).await),
1024 _ => None,
1025 }
1026 };
1027 (follow_up.redirect, follow_up.lookups) =
1028 tokio::join!(redirect, count_spf_lookups(dns, record));
1029 }
1030 build_spf(apex_txt, &follow_up)
1031}
1032
1033async fn discover_dmarc(dns: Dns<'_>, domain: &str, own: TxtLookup) -> DmarcDiscovery {
1042 let failed = |name: String, reason: String| DmarcDiscovery::LookupFailed { name, reason };
1043 let own = match own {
1044 Ok(own) => own,
1045 Err(reason) => return failed(format!("_dmarc.{domain}"), reason),
1046 };
1047 if select_dmarc(&own) != Selection::None {
1048 return DmarcDiscovery::Found(DmarcSource {
1049 txts: own,
1050 at: domain.to_string(),
1051 inherited: false,
1052 });
1053 }
1054 let Some(org) = crate::psl::registrable_parent(domain) else {
1056 return DmarcDiscovery::NotFound;
1057 };
1058 let mut current = domain;
1059 for _ in 0..MAX_DMARC_WALK {
1060 let Some((_, parent)) = current.split_once('.') else {
1061 break;
1062 };
1063 if parent.len() < org.len() {
1064 break;
1065 }
1066 let name = format!("_dmarc.{parent}");
1067 let txts = match dns.txt(&name).await {
1068 Ok(txts) => txts,
1069 Err(reason) => return failed(name, reason),
1070 };
1071 if select_dmarc(&txts) != Selection::None {
1072 return DmarcDiscovery::Found(DmarcSource {
1073 txts,
1074 at: parent.to_string(),
1075 inherited: true,
1076 });
1077 }
1078 current = parent;
1079 }
1080 DmarcDiscovery::NotFound
1081}
1082
1083fn mail_exchangers(mx: &Result<Vec<crate::dns::DnsRecord>>) -> MailExchangers {
1085 let Ok(records) = mx else {
1086 return MailExchangers::Unknown;
1087 };
1088 let mut hosts: Vec<(u16, String)> = records
1089 .iter()
1090 .filter_map(|r| match &r.data {
1091 RecordData::MX {
1092 preference,
1093 exchange,
1094 } => Some((
1095 *preference,
1096 exchange.trim_end_matches('.').to_ascii_lowercase(),
1097 )),
1098 _ => None,
1099 })
1100 .collect();
1101 if hosts.is_empty() {
1102 return MailExchangers::Absent;
1103 }
1104 if hosts.iter().any(|(_, h)| h.is_empty()) {
1105 return MailExchangers::Null;
1106 }
1107 hosts.sort();
1108 hosts.dedup_by(|a, b| a.1 == b.1);
1109 MailExchangers::Hosts(hosts.into_iter().map(|(_, h)| h).collect())
1110}
1111
1112async fn smtp_tlsa_records(
1119 dns: Dns<'_>,
1120 domain: &str,
1121 mx: Result<Vec<crate::dns::DnsRecord>>,
1122) -> TlsaSets {
1123 let Ok(mx) = mx else {
1124 return TlsaSets {
1125 sets: Vec::new(),
1126 lookup_failed: true,
1127 };
1128 };
1129 let mut hosts: Vec<String> = Vec::new();
1130 for record in &mx {
1131 if let RecordData::MX { exchange, .. } = &record.data {
1132 let host = exchange.trim_end_matches('.').to_ascii_lowercase();
1133 if !hosts.contains(&host) {
1134 hosts.push(host);
1135 }
1136 }
1137 }
1138 if hosts.iter().any(|h| h.is_empty()) {
1139 return TlsaSets::default();
1140 }
1141
1142 let owners: Vec<(String, String)> = if hosts.is_empty() {
1143 vec![("_25._tcp".to_string(), format!("_25._tcp.{domain}"))]
1144 } else {
1145 hosts
1146 .into_iter()
1147 .take(MAX_DANE_MX_HOSTS)
1148 .map(|host| {
1149 let owner = format!("_25._tcp.{host}");
1150 (owner.clone(), owner)
1151 })
1152 .collect()
1153 };
1154 let results = futures::future::join_all(
1155 owners
1156 .iter()
1157 .map(|(_, owner)| Box::pin(dns.resolve(owner, RecordType::TLSA))),
1158 )
1159 .await;
1160 let mut tlsa = TlsaSets::default();
1161 for ((scope, _), result) in owners.into_iter().zip(results) {
1162 match result {
1163 Ok(set) => tlsa.sets.push((scope, set)),
1164 Err(_) => tlsa.lookup_failed = true,
1165 }
1166 }
1167 tlsa
1168}
1169
1170pub async fn lookup_email_posture(
1181 resolver: &DnsResolver,
1182 domain: &str,
1183 nameserver: Option<&str>,
1184) -> Result<EmailPosture> {
1185 let domain = normalize_domain(domain)?;
1186 let dns = Dns {
1187 resolver,
1188 nameserver,
1189 };
1190
1191 let dmarc_name = format!("_dmarc.{domain}");
1192 let mta_sts_name = format!("_mta-sts.{domain}");
1193 let bimi_name = format!("default._bimi.{domain}");
1194 let https_tlsa_name = format!("_443._tcp.{domain}");
1195
1196 let (apex_txt, dmarc, mta_sts, bimi, mx, https_tlsa) = tokio::join!(
1200 Box::pin(dns.txt(&domain)),
1201 Box::pin(dns.txt(&dmarc_name)),
1202 Box::pin(dns.txt(&mta_sts_name)),
1203 Box::pin(dns.txt(&bimi_name)),
1204 Box::pin(dns.resolve(&domain, RecordType::MX)),
1205 Box::pin(dns.resolve(&https_tlsa_name, RecordType::TLSA)),
1206 );
1207
1208 let mx_state = mail_exchangers(&mx);
1209
1210 let (spf, dmarc_discovery, mut tlsa) = tokio::join!(
1214 Box::pin(assess_spf(dns, &apex_txt)),
1215 Box::pin(discover_dmarc(dns, &domain, dmarc)),
1216 Box::pin(smtp_tlsa_records(dns, &domain, mx)),
1217 );
1218 match https_tlsa {
1219 Ok(set) => tlsa.sets.push(("_443._tcp".to_string(), set)),
1220 Err(_) => tlsa.lookup_failed = true,
1221 }
1222
1223 let dmarc = build_dmarc(&domain, &dmarc_discovery);
1224 let mta_sts = build_mta_sts(&mta_sts);
1225 let bimi = build_bimi(&bimi);
1226 let dane = build_dane(&tlsa);
1227 let notes = build_notes(&spf, &dmarc, &mta_sts, &bimi, &dane);
1228
1229 Ok(EmailPosture {
1230 domain,
1231 spf: spf.policy,
1232 dmarc: dmarc.policy,
1233 mta_sts,
1234 bimi,
1235 dane,
1236 mx: mx_state,
1237 notes,
1238 })
1239}
1240
1241#[cfg(test)]
1242mod tests {
1243 use super::*;
1244
1245 fn strings(values: &[&str]) -> Vec<String> {
1246 values.iter().map(|v| v.to_string()).collect()
1247 }
1248
1249 fn own_dmarc(domain: &str, txts: &[&str]) -> DmarcDiscovery {
1250 DmarcDiscovery::Found(DmarcSource {
1251 txts: strings(txts),
1252 at: domain.to_string(),
1253 inherited: false,
1254 })
1255 }
1256
1257 #[test]
1258 fn spf_qualifier_parsing() {
1259 assert_eq!(
1260 parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
1261 Some("-".to_string())
1262 );
1263 assert_eq!(
1264 parse_spf_all_qualifier("v=spf1 mx ~all"),
1265 Some("~".to_string())
1266 );
1267 assert_eq!(
1268 parse_spf_all_qualifier("v=spf1 +all"),
1269 Some("+".to_string())
1270 );
1271 assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
1272 }
1273
1274 #[test]
1275 fn spf_verdict_bands() {
1276 assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
1277 assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
1278 assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
1279 assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
1280 assert_eq!(spf_verdict(None), PostureVerdict::Weak);
1281 }
1282
1283 #[test]
1284 fn verdict_as_str_matches_serde_label() {
1285 for v in [
1287 PostureVerdict::Absent,
1288 PostureVerdict::Weak,
1289 PostureVerdict::Moderate,
1290 PostureVerdict::Strict,
1291 PostureVerdict::Present,
1292 ] {
1293 assert_eq!(serde_json::to_value(v).unwrap(), v.as_str());
1294 }
1295 }
1296
1297 #[test]
1298 fn version_tag_must_be_followed_by_a_delimiter() {
1299 assert!(!has_version_tag("v=spf10 -all", "v=spf1", false));
1301 assert!(has_version_tag("v=spf1 -all", "v=spf1", false));
1302 assert!(has_version_tag("V=SPF1 -all", "v=spf1", false));
1303 assert!(has_version_tag("v=spf1", "v=spf1", false));
1304 assert!(!has_version_tag("v=spf1;-all", "v=spf1", false));
1305 assert!(has_version_tag("v=DMARC1; p=reject", "v=DMARC1", true));
1306 assert!(has_version_tag("v=DMARC1;p=reject", "v=DMARC1", true));
1307 assert!(!has_version_tag("v=DMARC10; p=reject", "v=DMARC1", true));
1308 assert!(
1309 !has_version_tag("v=é", "v=spf1", false),
1310 "no panic on short multibyte"
1311 );
1312 assert_eq!(select_spf(&strings(&["v=spf10 -all"])), Selection::None);
1313 }
1314
1315 #[test]
1316 fn multiple_spf_records_are_a_permerror() {
1317 let spf = build_spf(
1319 &strings(&["v=spf1 -all", "v=spf1 include:x.test ~all"]),
1320 &SpfFollowUp::default(),
1321 );
1322 assert!(spf.policy.present);
1323 assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1324 assert_eq!(spf.policy.all_qualifier, None);
1325 assert!(
1326 spf.notes.iter().any(|n| n.contains("permerror")),
1327 "{:?}",
1328 spf.notes
1329 );
1330 }
1331
1332 #[test]
1333 fn multiple_dmarc_records_disable_dmarc() {
1334 let source = own_dmarc("x.test", &["v=DMARC1; p=reject", "v=DMARC1; p=none"]);
1336 let dmarc = build_dmarc("x.test", &source);
1337 assert!(dmarc.policy.present);
1338 assert_eq!(dmarc.policy.verdict, PostureVerdict::Absent);
1339 assert!(
1340 dmarc.notes[0].contains("2 DMARC records"),
1341 "{:?}",
1342 dmarc.notes
1343 );
1344 }
1345
1346 #[test]
1347 fn dmarc_parsing_and_verdict() {
1348 let record =
1349 "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
1350 let dmarc = build_dmarc("x.com", &own_dmarc("x.com", &[record])).policy;
1351 assert!(dmarc.present);
1352 assert_eq!(dmarc.policy.as_deref(), Some("reject"));
1353 assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
1354 assert_eq!(dmarc.percent, Some(100));
1355 assert_eq!(dmarc.aggregate_reports.len(), 2);
1356 assert_eq!(dmarc.verdict, PostureVerdict::Strict);
1357 }
1358
1359 #[test]
1360 fn dmarc_none_is_weak_and_absent_is_absent() {
1361 let weak = build_dmarc("x.test", &own_dmarc("x.test", &["v=DMARC1; p=none"]));
1362 assert_eq!(weak.policy.verdict, PostureVerdict::Weak);
1363 assert!(weak.notes[0].contains("p=none (monitoring only)"));
1364 let absent = build_dmarc("x.test", &own_dmarc("x.test", &["v=spf1 -all"]));
1365 assert!(!absent.policy.present);
1366 assert_eq!(absent.policy.verdict, PostureVerdict::Absent);
1367 }
1368
1369 #[test]
1370 fn dmarc_partial_pct_downgrades_one_band() {
1371 let verdict = |record: &str| {
1373 let dmarc = build_dmarc("x.test", &own_dmarc("x.test", &[record]));
1374 (dmarc.policy.verdict, dmarc.notes)
1375 };
1376 let (v, notes) = verdict("v=DMARC1; p=reject; pct=0");
1377 assert_eq!(v, PostureVerdict::Moderate);
1378 assert!(notes.iter().any(|n| n.contains("pct=0")), "{notes:?}");
1379 assert_eq!(
1380 verdict("v=DMARC1; p=quarantine; pct=50").0,
1381 PostureVerdict::Weak
1382 );
1383 assert_eq!(
1384 verdict("v=DMARC1; p=reject; pct=100").0,
1385 PostureVerdict::Strict
1386 );
1387 assert_eq!(verdict("v=DMARC1; p=none; pct=10").0, PostureVerdict::Weak);
1388 }
1389
1390 #[test]
1391 fn inherited_dmarc_applies_sp_then_p() {
1392 let inherited = |txt: &str| {
1393 DmarcDiscovery::Found(DmarcSource {
1394 txts: strings(&[txt]),
1395 at: "org.test".to_string(),
1396 inherited: true,
1397 })
1398 };
1399 let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=none; sp=reject"));
1400 assert_eq!(dmarc.policy.verdict, PostureVerdict::Strict);
1401 assert_eq!(dmarc.policy.effective_policy.as_deref(), Some("reject"));
1402 assert!(dmarc.notes[0].contains("inherits"), "{:?}", dmarc.notes);
1403 assert!(dmarc.notes[0].contains("_dmarc.org.test"));
1404
1405 let dmarc = build_dmarc("mail.org.test", &inherited("v=DMARC1; p=quarantine"));
1406 assert_eq!(dmarc.policy.verdict, PostureVerdict::Moderate);
1407 assert_eq!(dmarc.policy.effective_policy.as_deref(), Some("quarantine"));
1408 }
1409
1410 #[test]
1411 fn spf_permerror_is_structured() {
1412 let multiple = build_spf(
1413 &strings(&["v=spf1 -all", "v=spf1 ~all"]),
1414 &SpfFollowUp::default(),
1415 );
1416 assert_eq!(
1417 multiple.policy.permerror,
1418 Some(SpfPermerror::MultipleRecords)
1419 );
1420
1421 let heavy = build_spf(
1422 &strings(&["v=spf1 include:a.test -all"]),
1423 &SpfFollowUp {
1424 lookups: MAX_SPF_LOOKUPS + 1,
1425 ..SpfFollowUp::default()
1426 },
1427 );
1428 assert_eq!(heavy.policy.permerror, Some(SpfPermerror::TooManyLookups));
1429
1430 let fine = build_spf(&strings(&["v=spf1 -all"]), &SpfFollowUp::default());
1431 assert_eq!(fine.policy.permerror, None);
1432
1433 let redirect = |outcome| SpfFollowUp {
1434 redirect: Some(outcome),
1435 ..SpfFollowUp::default()
1436 };
1437 let failed = build_spf(
1438 &strings(&["v=spf1 redirect=_spf.example.test"]),
1439 &redirect(RedirectOutcome::Failed {
1440 target: "_spf.example.test".into(),
1441 reason: "no SPF record".into(),
1442 }),
1443 );
1444 assert_eq!(failed.policy.permerror, Some(SpfPermerror::RedirectFailed));
1445
1446 let unknown = build_spf(
1448 &strings(&["v=spf1 redirect=_spf.example.test"]),
1449 &redirect(RedirectOutcome::LookupFailed {
1450 target: "_spf.example.test".into(),
1451 reason: "timeout".into(),
1452 }),
1453 );
1454 assert_eq!(unknown.policy.verdict, PostureVerdict::Unknown);
1455 assert_eq!(unknown.policy.permerror, None);
1456 }
1457
1458 #[test]
1459 fn spf_prefers_the_spf_record_among_txt() {
1460 let txts = strings(&[
1462 "google-site-verification=abc123",
1463 "v=spf1 include:_spf.google.com -all",
1464 ]);
1465 let spf = build_spf(&txts, &SpfFollowUp::default()).policy;
1466 assert!(spf.present);
1467 assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
1468 assert_eq!(spf.verdict, PostureVerdict::Strict);
1469 }
1470
1471 #[test]
1472 fn spf_redirect_parsing_prefers_all() {
1473 assert_eq!(
1474 spf_terminal("v=spf1 redirect=_spf.x.test"),
1475 SpfTerminal::Redirect("_spf.x.test".to_string())
1476 );
1477 assert_eq!(
1478 spf_terminal("v=spf1 Redirect=_spf.x.test"),
1479 SpfTerminal::Redirect("_spf.x.test".to_string())
1480 );
1481 assert_eq!(
1483 spf_terminal("v=spf1 redirect=_spf.x.test ~all"),
1484 SpfTerminal::All("~".to_string())
1485 );
1486 assert_eq!(spf_terminal("v=spf1 mx"), SpfTerminal::Neither);
1487 }
1488
1489 #[test]
1490 fn bimi_and_mta_sts_parse_tags() {
1491 let bimi = build_bimi(&Ok(vec![
1492 "v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into(),
1493 ]));
1494 assert!(bimi.present);
1495 assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
1496 assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
1497
1498 let mta = build_mta_sts(&Ok(vec!["v=STSv1; id=20240101T000000".into()]));
1499 assert!(mta.present);
1500 assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
1501 assert_eq!(mta.verdict, PostureVerdict::Present);
1502 }
1503
1504 #[test]
1505 fn notes_flag_a_spoofable_domain() {
1506 let spf = build_spf(&[], &SpfFollowUp::default());
1508 let dmarc = build_dmarc("x.test", &DmarcDiscovery::NotFound);
1509 let mta = build_mta_sts(&Ok(Vec::new()));
1510 let dane = build_dane(&TlsaSets::default());
1511 let notes = build_notes(&spf, &dmarc, &mta, &build_bimi(&Ok(Vec::new())), &dane);
1512 assert!(notes[0].contains("No DMARC"), "DMARC first: {notes:?}");
1513 assert!(notes.iter().any(|n| n.contains("No SPF")));
1514 assert!(notes.iter().any(|n| n.contains("MTA-STS")));
1515 assert!(notes.iter().any(|n| n.contains("DANE")));
1516 }
1517
1518 use hickory_resolver::proto::op::ResponseCode;
1521 use hickory_resolver::proto::rr::rdata as wire;
1522 use hickory_resolver::proto::rr::{Name, RData, RecordType as WireType};
1523
1524 use crate::dns::test_support::{
1525 mock_dns_resolver, mock_dns_resolver_default, spawn_mock_dns_fn, MockReply,
1526 };
1527
1528 fn txt(value: &str) -> MockReply {
1529 MockReply::Answer(vec![RData::TXT(wire::TXT::new(vec![value.to_string()]))])
1530 }
1531
1532 fn mx(preference: u16, exchange: &str) -> RData {
1533 RData::MX(wire::MX::new(
1534 preference,
1535 Name::from_ascii(exchange).unwrap(),
1536 ))
1537 }
1538
1539 fn tlsa() -> MockReply {
1540 MockReply::Answer(vec![RData::TLSA(wire::TLSA::new(
1541 wire::tlsa::CertUsage::from(3),
1542 wire::tlsa::Selector::from(1),
1543 wire::tlsa::Matching::from(1),
1544 vec![0xAB, 0xCD],
1545 ))])
1546 }
1547
1548 #[tokio::test]
1552 async fn dane_smtp_is_read_at_the_mx_hosts() {
1553 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1554 ("mail.test", WireType::MX) => {
1555 MockReply::Answer(vec![mx(20, "mx2.mail.test."), mx(10, "mx1.mail.test.")])
1556 }
1557 ("_25._tcp.mx2.mail.test", WireType::TLSA) => tlsa(),
1558 _ => MockReply::NxDomain,
1559 })
1560 .await;
1561 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "mail.test", None)
1562 .await
1563 .unwrap();
1564 assert!(posture.dane.present, "{:?}", posture.dane);
1565 assert_eq!(posture.dane.records.len(), 1);
1566 assert_eq!(posture.dane.records[0].scope, "_25._tcp.mx2.mail.test");
1567 assert_eq!(
1568 posture.mx,
1569 MailExchangers::Hosts(vec!["mx1.mail.test".into(), "mx2.mail.test".into()])
1570 );
1571 }
1572
1573 #[tokio::test]
1575 async fn dane_smtp_falls_back_to_the_domain_without_mx() {
1576 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1577 ("_25._tcp.nomx.test", WireType::TLSA) => tlsa(),
1578 _ => MockReply::NoData,
1579 })
1580 .await;
1581 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "nomx.test", None)
1582 .await
1583 .unwrap();
1584 assert_eq!(posture.dane.records.len(), 1, "{:?}", posture.dane);
1585 assert_eq!(posture.dane.records[0].scope, "_25._tcp");
1586 assert_eq!(posture.mx, MailExchangers::Absent);
1587 }
1588
1589 #[tokio::test]
1590 async fn null_mx_and_failed_mx_are_distinguished() {
1591 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1592 ("null.test", WireType::MX) => MockReply::Answer(vec![mx(0, ".")]),
1593 ("broken.test", WireType::MX) => MockReply::Rcode(ResponseCode::ServFail),
1594 _ => MockReply::NoData,
1595 })
1596 .await;
1597 let resolver = mock_dns_resolver_default(port);
1598 let null = lookup_email_posture(&resolver, "null.test", None)
1599 .await
1600 .unwrap();
1601 assert_eq!(null.mx, MailExchangers::Null);
1602 let broken = lookup_email_posture(&resolver, "broken.test", None)
1603 .await
1604 .unwrap();
1605 assert_eq!(broken.mx, MailExchangers::Unknown);
1606 }
1607
1608 #[tokio::test]
1611 async fn dmarc_is_inherited_from_a_parent() {
1612 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1613 ("_dmarc.org.test", WireType::TXT) => txt("v=DMARC1; p=none; sp=reject"),
1614 _ => MockReply::NxDomain,
1615 })
1616 .await;
1617 let posture =
1618 lookup_email_posture(&mock_dns_resolver_default(port), "news.mail.org.test", None)
1619 .await
1620 .unwrap();
1621 assert!(posture.dmarc.present);
1622 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1623 assert!(
1624 posture
1625 .notes
1626 .iter()
1627 .any(|n| n.contains("inherits") && n.contains("_dmarc.org.test")),
1628 "{:?}",
1629 posture.notes
1630 );
1631 }
1632
1633 #[tokio::test]
1637 async fn spf_redirect_is_followed() {
1638 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1639 ("brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.brand.test"),
1640 ("_spf.brand.test", WireType::TXT) => txt("v=spf1 redirect=_spf.esp.test"),
1641 ("_spf.esp.test", WireType::TXT) => txt("v=spf1 ip4:192.0.2.0/24 -all"),
1642 ("broken.test", WireType::TXT) => txt("v=spf1 redirect=_spf.nowhere.test"),
1643 _ => MockReply::NxDomain,
1644 })
1645 .await;
1646 let resolver = mock_dns_resolver_default(port);
1647
1648 let posture = lookup_email_posture(&resolver, "brand.test", None)
1649 .await
1650 .unwrap();
1651 assert_eq!(
1652 posture.spf.verdict,
1653 PostureVerdict::Strict,
1654 "{:?}",
1655 posture.notes
1656 );
1657 assert_eq!(posture.spf.all_qualifier.as_deref(), Some("-"));
1658 assert!(posture.notes.iter().any(|n| n.contains("_spf.esp.test")));
1659
1660 let posture = lookup_email_posture(&resolver, "broken.test", None)
1662 .await
1663 .unwrap();
1664 assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1665 assert!(
1666 posture.notes.iter().any(|n| n.contains("permerror")),
1667 "{:?}",
1668 posture.notes
1669 );
1670 }
1671
1672 #[test]
1673 fn spf_lookup_terms_count_the_querying_mechanisms() {
1674 let (count, targets) = spf_lookup_terms(
1676 "v=spf1 include:_spf.a.test ~include:_spf.b.test a a:x.test/24 MX:y.test \
1677 ptr exists:%{i}.z.test ip4:192.0.2.1 -all",
1678 );
1679 assert_eq!(count, 7);
1680 assert_eq!(targets, vec!["_spf.a.test", "_spf.b.test"]);
1681 assert_eq!(
1683 spf_lookup_terms("v=spf1 redirect=_spf.c.test"),
1684 (1, vec!["_spf.c.test".to_string()])
1685 );
1686 assert_eq!(spf_lookup_terms("v=spf1 redirect=_spf.c.test -all").0, 0);
1687 }
1688
1689 #[test]
1690 fn spf_over_the_lookup_limit_is_a_permerror() {
1691 let record = strings(&["v=spf1 include:a.test -all"]);
1692 let at_limit = SpfFollowUp {
1693 redirect: None,
1694 lookups: MAX_SPF_LOOKUPS,
1695 };
1696 assert_eq!(
1697 build_spf(&record, &at_limit).policy.verdict,
1698 PostureVerdict::Strict
1699 );
1700 let over = SpfFollowUp {
1701 redirect: None,
1702 lookups: MAX_SPF_LOOKUPS + 1,
1703 };
1704 let spf = build_spf(&record, &over);
1705 assert_eq!(spf.policy.verdict, PostureVerdict::Weak);
1706 assert!(
1707 spf.notes.iter().any(|n| n.contains("§4.6.4")),
1708 "{:?}",
1709 spf.notes
1710 );
1711 }
1712
1713 #[tokio::test]
1717 async fn spf_lookups_are_counted_through_includes() {
1718 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1719 ("heavy.test", WireType::TXT) => {
1720 txt("v=spf1 include:_a.heavy.test include:_b.heavy.test -all")
1721 }
1722 ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1723 ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1724 _ => MockReply::NxDomain,
1725 })
1726 .await;
1727 let resolver = mock_dns_resolver_default(port);
1728
1729 let posture = lookup_email_posture(&resolver, "heavy.test", None)
1731 .await
1732 .unwrap();
1733 assert_eq!(
1734 posture.spf.verdict,
1735 PostureVerdict::Strict,
1736 "{:?}",
1737 posture.notes
1738 );
1739
1740 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1741 ("heavy.test", WireType::TXT) => {
1742 txt("v=spf1 include:_a.heavy.test include:_b.heavy.test a -all")
1743 }
1744 ("_a.heavy.test", WireType::TXT) => txt("v=spf1 a mx ptr exists:x.test a:y.test ~all"),
1745 ("_b.heavy.test", WireType::TXT) => txt("v=spf1 mx:m1.test mx:m2.test mx:m3.test ~all"),
1746 _ => MockReply::NxDomain,
1747 })
1748 .await;
1749 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "heavy.test", None)
1750 .await
1751 .unwrap();
1752 assert_eq!(posture.spf.verdict, PostureVerdict::Weak);
1753 assert!(
1754 posture.notes.iter().any(|n| n.contains("§4.6.4")),
1755 "{:?}",
1756 posture.notes
1757 );
1758 }
1759
1760 #[tokio::test]
1763 async fn failed_lookups_are_unknown_not_absent() {
1764 let port = spawn_mock_dns_fn(|_, _| MockReply::Rcode(ResponseCode::ServFail)).await;
1765 let posture = lookup_email_posture(&mock_dns_resolver_default(port), "down.test", None)
1766 .await
1767 .unwrap();
1768 assert_eq!(posture.spf.verdict, PostureVerdict::Unknown);
1769 assert_eq!(posture.dmarc.verdict, PostureVerdict::Unknown);
1770 assert_eq!(posture.mta_sts.verdict, PostureVerdict::Unknown);
1771 assert_eq!(posture.bimi.verdict, PostureVerdict::Unknown);
1772 assert_eq!(posture.dane.verdict, PostureVerdict::Unknown);
1773 assert!(
1774 !posture.notes.iter().any(|n| n.contains("spoofable")
1775 || n.contains("No SPF")
1776 || n.contains("not configured")
1777 || n.contains("No DANE")),
1778 "{:?}",
1779 posture.notes
1780 );
1781 assert!(posture
1782 .notes
1783 .iter()
1784 .any(|n| n.contains("SPF lookup failed")));
1785 assert!(posture.notes.iter().any(|n| n.contains("DMARC lookup")));
1786 }
1787
1788 #[tokio::test]
1791 async fn dmarc_walk_stops_at_the_organizational_domain() {
1792 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1793 ("_dmarc.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1794 _ => MockReply::NxDomain,
1795 })
1796 .await;
1797 let posture =
1798 lookup_email_posture(&mock_dns_resolver_default(port), "mail.example.co.uk", None)
1799 .await
1800 .unwrap();
1801 assert_eq!(posture.dmarc.verdict, PostureVerdict::Absent);
1802
1803 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1805 ("_dmarc.example.co.uk", WireType::TXT) => txt("v=DMARC1; p=reject"),
1806 _ => MockReply::NxDomain,
1807 })
1808 .await;
1809 let posture = lookup_email_posture(
1810 &mock_dns_resolver_default(port),
1811 "a.mail.example.co.uk",
1812 None,
1813 )
1814 .await
1815 .unwrap();
1816 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1817 }
1818
1819 #[tokio::test]
1822 async fn posture_queries_the_given_nameserver() {
1823 let port = spawn_mock_dns_fn(|qname, qtype| match (qname, qtype) {
1824 ("ns.test", WireType::TXT) => txt("v=spf1 -all"),
1825 ("_dmarc.ns.test", WireType::TXT) => txt("v=DMARC1; p=reject"),
1826 _ => MockReply::NxDomain,
1827 })
1828 .await;
1829 let posture = lookup_email_posture(&mock_dns_resolver(port), "ns.test", Some("127.0.0.1"))
1831 .await
1832 .unwrap();
1833 assert_eq!(posture.spf.verdict, PostureVerdict::Strict);
1834 assert_eq!(posture.dmarc.verdict, PostureVerdict::Strict);
1835 }
1836}