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 {
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
1019async 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
1042async 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 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
1092fn 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
1121async 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
1179pub 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 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 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 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 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 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 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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 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 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 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 #[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 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 #[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 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 #[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 #[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 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 #[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 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}