1use std::collections::HashMap;
14
15use serde::{Deserialize, Serialize};
16
17use crate::dns::{DnsResolver, RecordData, RecordType};
18use crate::error::Result;
19use crate::validation::normalize_domain;
20
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum PostureVerdict {
25 Absent,
27 Weak,
29 Moderate,
31 Strict,
33 Present,
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize)]
39pub struct SpfPolicy {
40 pub present: bool,
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub record: Option<String>,
43 #[serde(skip_serializing_if = "Option::is_none")]
46 pub all_qualifier: Option<String>,
47 pub verdict: PostureVerdict,
48}
49
50#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct DmarcPolicy {
53 pub present: bool,
54 #[serde(skip_serializing_if = "Option::is_none")]
55 pub record: Option<String>,
56 #[serde(skip_serializing_if = "Option::is_none")]
58 pub policy: Option<String>,
59 #[serde(skip_serializing_if = "Option::is_none")]
61 pub subdomain_policy: Option<String>,
62 #[serde(skip_serializing_if = "Vec::is_empty")]
64 pub aggregate_reports: Vec<String>,
65 #[serde(skip_serializing_if = "Option::is_none")]
67 pub percent: Option<u8>,
68 pub verdict: PostureVerdict,
69}
70
71#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct MtaStsPolicy {
75 pub present: bool,
76 #[serde(skip_serializing_if = "Option::is_none")]
77 pub record: Option<String>,
78 #[serde(skip_serializing_if = "Option::is_none")]
80 pub id: Option<String>,
81 pub verdict: PostureVerdict,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct BimiPolicy {
87 pub present: bool,
88 #[serde(skip_serializing_if = "Option::is_none")]
89 pub record: Option<String>,
90 #[serde(skip_serializing_if = "Option::is_none")]
92 pub logo_url: Option<String>,
93 #[serde(skip_serializing_if = "Option::is_none")]
95 pub authority_url: Option<String>,
96 pub verdict: PostureVerdict,
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct TlsaRecord {
102 pub scope: String,
104 pub cert_usage: u8,
105 pub selector: u8,
106 pub matching: u8,
107 pub cert_data: String,
108}
109
110#[derive(Debug, Clone, Serialize, Deserialize)]
112pub struct DanePolicy {
113 pub present: bool,
114 #[serde(skip_serializing_if = "Vec::is_empty")]
115 pub records: Vec<TlsaRecord>,
116 pub verdict: PostureVerdict,
117}
118
119#[derive(Debug, Clone, Serialize, Deserialize)]
121pub struct EmailPosture {
122 pub domain: String,
123 pub spf: SpfPolicy,
124 pub dmarc: DmarcPolicy,
125 pub mta_sts: MtaStsPolicy,
126 pub bimi: BimiPolicy,
127 pub dane: DanePolicy,
128 pub notes: Vec<String>,
130}
131
132fn parse_spf_all_qualifier(record: &str) -> Option<String> {
137 for token in record.split_whitespace() {
138 let t = token.to_ascii_lowercase();
139 if t == "all" {
140 return Some("+".to_string());
141 }
142 if let Some(qual) = t.strip_suffix("all") {
143 if matches!(qual, "-" | "~" | "?" | "+") {
144 return Some(qual.to_string());
145 }
146 }
147 }
148 None
149}
150
151fn spf_verdict(all_qualifier: Option<&str>) -> PostureVerdict {
153 match all_qualifier {
154 Some("-") => PostureVerdict::Strict,
155 Some("~") => PostureVerdict::Moderate,
156 Some(_) | None => PostureVerdict::Weak,
159 }
160}
161
162fn parse_tag_value(record: &str) -> HashMap<String, String> {
165 record
166 .split(';')
167 .filter_map(|pair| {
168 let (k, v) = pair.split_once('=')?;
169 Some((k.trim().to_ascii_lowercase(), v.trim().to_string()))
170 })
171 .collect()
172}
173
174fn dmarc_verdict(policy: Option<&str>) -> PostureVerdict {
176 match policy.map(|p| p.to_ascii_lowercase()) {
177 Some(ref p) if p == "reject" => PostureVerdict::Strict,
178 Some(ref p) if p == "quarantine" => PostureVerdict::Moderate,
179 Some(ref p) if p == "none" => PostureVerdict::Weak,
180 _ => PostureVerdict::Weak,
181 }
182}
183
184fn find_txt_with_prefix<'a>(records: &'a [String], version_prefix: &str) -> Option<&'a String> {
187 let prefix = version_prefix.to_ascii_lowercase();
188 records
189 .iter()
190 .find(|t| t.trim().to_ascii_lowercase().starts_with(&prefix))
191}
192
193fn txt_strings(records: &[crate::dns::DnsRecord]) -> Vec<String> {
195 records
196 .iter()
197 .filter_map(|r| match &r.data {
198 RecordData::TXT { text } => Some(text.clone()),
199 _ => None,
200 })
201 .collect()
202}
203
204fn build_spf(apex_txt: &[String]) -> SpfPolicy {
207 match find_txt_with_prefix(apex_txt, "v=spf1") {
208 Some(record) => {
209 let all_qualifier = parse_spf_all_qualifier(record);
210 SpfPolicy {
211 present: true,
212 verdict: spf_verdict(all_qualifier.as_deref()),
213 all_qualifier,
214 record: Some(record.clone()),
215 }
216 }
217 None => SpfPolicy {
218 present: false,
219 record: None,
220 all_qualifier: None,
221 verdict: PostureVerdict::Absent,
222 },
223 }
224}
225
226fn build_dmarc(dmarc_txt: &[String]) -> DmarcPolicy {
227 match find_txt_with_prefix(dmarc_txt, "v=dmarc1") {
228 Some(record) => {
229 let tags = parse_tag_value(record);
230 let policy = tags.get("p").cloned();
231 let aggregate_reports = tags
232 .get("rua")
233 .map(|rua| {
234 rua.split(',')
235 .map(|s| s.trim().to_string())
236 .filter(|s| !s.is_empty())
237 .collect()
238 })
239 .unwrap_or_default();
240 DmarcPolicy {
241 present: true,
242 verdict: dmarc_verdict(policy.as_deref()),
243 subdomain_policy: tags.get("sp").cloned(),
244 percent: tags.get("pct").and_then(|p| p.parse().ok()),
245 aggregate_reports,
246 policy,
247 record: Some(record.clone()),
248 }
249 }
250 None => DmarcPolicy {
251 present: false,
252 record: None,
253 policy: None,
254 subdomain_policy: None,
255 aggregate_reports: Vec::new(),
256 percent: None,
257 verdict: PostureVerdict::Absent,
258 },
259 }
260}
261
262fn build_mta_sts(txt: &[String]) -> MtaStsPolicy {
263 match find_txt_with_prefix(txt, "v=stsv1") {
264 Some(record) => {
265 let tags = parse_tag_value(record);
266 MtaStsPolicy {
267 present: true,
268 id: tags.get("id").cloned(),
269 record: Some(record.clone()),
270 verdict: PostureVerdict::Present,
271 }
272 }
273 None => MtaStsPolicy {
274 present: false,
275 record: None,
276 id: None,
277 verdict: PostureVerdict::Absent,
278 },
279 }
280}
281
282fn build_bimi(txt: &[String]) -> BimiPolicy {
283 match find_txt_with_prefix(txt, "v=bimi1") {
284 Some(record) => {
285 let tags = parse_tag_value(record);
286 BimiPolicy {
287 present: true,
288 logo_url: tags.get("l").filter(|s| !s.is_empty()).cloned(),
289 authority_url: tags.get("a").filter(|s| !s.is_empty()).cloned(),
290 record: Some(record.clone()),
291 verdict: PostureVerdict::Present,
292 }
293 }
294 None => BimiPolicy {
295 present: false,
296 record: None,
297 logo_url: None,
298 authority_url: None,
299 verdict: PostureVerdict::Absent,
300 },
301 }
302}
303
304fn build_dane(smtp: &[crate::dns::DnsRecord], https: &[crate::dns::DnsRecord]) -> DanePolicy {
305 let mut records = Vec::new();
306 let mut collect = |set: &[crate::dns::DnsRecord], scope: &str| {
307 for r in set {
308 if let RecordData::TLSA {
309 cert_usage,
310 selector,
311 matching,
312 cert_data,
313 } = &r.data
314 {
315 records.push(TlsaRecord {
316 scope: scope.to_string(),
317 cert_usage: *cert_usage,
318 selector: *selector,
319 matching: *matching,
320 cert_data: cert_data.clone(),
321 });
322 }
323 }
324 };
325 collect(smtp, "_25._tcp");
326 collect(https, "_443._tcp");
327 DanePolicy {
328 present: !records.is_empty(),
329 verdict: if records.is_empty() {
330 PostureVerdict::Absent
331 } else {
332 PostureVerdict::Present
333 },
334 records,
335 }
336}
337
338fn build_notes(
340 spf: &SpfPolicy,
341 dmarc: &DmarcPolicy,
342 mta_sts: &MtaStsPolicy,
343 dane: &DanePolicy,
344) -> Vec<String> {
345 let mut notes = Vec::new();
346
347 match dmarc.verdict {
348 PostureVerdict::Absent => notes
349 .push("No DMARC record — the domain is trivially spoofable in phishing.".to_string()),
350 PostureVerdict::Weak => notes.push(
351 "DMARC policy is p=none (monitoring only) — receivers will not reject spoofed mail."
352 .to_string(),
353 ),
354 PostureVerdict::Moderate => notes.push(
355 "DMARC policy is p=quarantine — spoofed mail is filtered but not rejected.".to_string(),
356 ),
357 _ => {}
358 }
359
360 match spf.verdict {
361 PostureVerdict::Absent => {
362 notes.push("No SPF record — sending sources are undeclared.".to_string());
363 }
364 PostureVerdict::Weak => notes.push(
365 "SPF does not end in -all/~all — it permits unlisted senders (spoofable).".to_string(),
366 ),
367 PostureVerdict::Moderate => {
368 notes.push("SPF uses ~all (softfail); -all provides stricter enforcement.".to_string());
369 }
370 _ => {}
371 }
372
373 if !mta_sts.present {
374 notes.push("MTA-STS is not configured — SMTP is vulnerable to downgrade.".to_string());
375 }
376 if !dane.present {
377 notes.push("No DANE (TLSA) records — no DNS-based TLS pinning for mail/HTTPS.".to_string());
378 }
379
380 notes
381}
382
383pub async fn lookup_email_posture(resolver: &DnsResolver, domain: &str) -> Result<EmailPosture> {
389 let domain = normalize_domain(domain)?;
390
391 let dmarc_name = format!("_dmarc.{domain}");
392 let mta_sts_name = format!("_mta-sts.{domain}");
393 let bimi_name = format!("default._bimi.{domain}");
394 let smtp_tlsa_name = format!("_25._tcp.{domain}");
395 let https_tlsa_name = format!("_443._tcp.{domain}");
396
397 let (apex, dmarc, mta_sts, bimi, smtp_tlsa, https_tlsa) = tokio::join!(
401 Box::pin(resolver.resolve(&domain, RecordType::TXT, None)),
402 Box::pin(resolver.resolve(&dmarc_name, RecordType::TXT, None)),
403 Box::pin(resolver.resolve(&mta_sts_name, RecordType::TXT, None)),
404 Box::pin(resolver.resolve(&bimi_name, RecordType::TXT, None)),
405 Box::pin(resolver.resolve(&smtp_tlsa_name, RecordType::TLSA, None)),
406 Box::pin(resolver.resolve(&https_tlsa_name, RecordType::TLSA, None)),
407 );
408
409 let apex = apex.unwrap_or_default();
410 let dmarc = dmarc.unwrap_or_default();
411 let mta_sts = mta_sts.unwrap_or_default();
412 let bimi = bimi.unwrap_or_default();
413 let smtp_tlsa = smtp_tlsa.unwrap_or_default();
414 let https_tlsa = https_tlsa.unwrap_or_default();
415
416 let spf = build_spf(&txt_strings(&apex));
417 let dmarc = build_dmarc(&txt_strings(&dmarc));
418 let mta_sts = build_mta_sts(&txt_strings(&mta_sts));
419 let bimi = build_bimi(&txt_strings(&bimi));
420 let dane = build_dane(&smtp_tlsa, &https_tlsa);
421 let notes = build_notes(&spf, &dmarc, &mta_sts, &dane);
422
423 Ok(EmailPosture {
424 domain,
425 spf,
426 dmarc,
427 mta_sts,
428 bimi,
429 dane,
430 notes,
431 })
432}
433
434#[cfg(test)]
435mod tests {
436 use super::*;
437
438 #[test]
439 fn spf_qualifier_parsing() {
440 assert_eq!(
441 parse_spf_all_qualifier("v=spf1 include:_spf.google.com -all"),
442 Some("-".to_string())
443 );
444 assert_eq!(
445 parse_spf_all_qualifier("v=spf1 mx ~all"),
446 Some("~".to_string())
447 );
448 assert_eq!(
449 parse_spf_all_qualifier("v=spf1 +all"),
450 Some("+".to_string())
451 );
452 assert_eq!(parse_spf_all_qualifier("v=spf1 a mx"), None);
453 }
454
455 #[test]
456 fn spf_verdict_bands() {
457 assert_eq!(spf_verdict(Some("-")), PostureVerdict::Strict);
458 assert_eq!(spf_verdict(Some("~")), PostureVerdict::Moderate);
459 assert_eq!(spf_verdict(Some("?")), PostureVerdict::Weak);
460 assert_eq!(spf_verdict(Some("+")), PostureVerdict::Weak);
461 assert_eq!(spf_verdict(None), PostureVerdict::Weak);
462 }
463
464 #[test]
465 fn dmarc_parsing_and_verdict() {
466 let record =
467 "v=DMARC1; p=reject; sp=quarantine; rua=mailto:a@x.com,mailto:b@x.com; pct=100";
468 let dmarc = build_dmarc(&[record.to_string()]);
469 assert!(dmarc.present);
470 assert_eq!(dmarc.policy.as_deref(), Some("reject"));
471 assert_eq!(dmarc.subdomain_policy.as_deref(), Some("quarantine"));
472 assert_eq!(dmarc.percent, Some(100));
473 assert_eq!(dmarc.aggregate_reports.len(), 2);
474 assert_eq!(dmarc.verdict, PostureVerdict::Strict);
475 }
476
477 #[test]
478 fn dmarc_none_is_weak_and_absent_is_absent() {
479 let weak = build_dmarc(&["v=DMARC1; p=none".to_string()]);
480 assert_eq!(weak.verdict, PostureVerdict::Weak);
481 let absent = build_dmarc(&["v=spf1 -all".to_string()]);
482 assert!(!absent.present);
483 assert_eq!(absent.verdict, PostureVerdict::Absent);
484 }
485
486 #[test]
487 fn spf_prefers_the_spf_record_among_txt() {
488 let txts = vec![
490 "google-site-verification=abc123".to_string(),
491 "v=spf1 include:_spf.google.com -all".to_string(),
492 ];
493 let spf = build_spf(&txts);
494 assert!(spf.present);
495 assert_eq!(spf.all_qualifier.as_deref(), Some("-"));
496 assert_eq!(spf.verdict, PostureVerdict::Strict);
497 }
498
499 #[test]
500 fn bimi_and_mta_sts_parse_tags() {
501 let bimi =
502 build_bimi(&["v=BIMI1; l=https://x.com/logo.svg; a=https://x.com/vmc.pem".into()]);
503 assert!(bimi.present);
504 assert_eq!(bimi.logo_url.as_deref(), Some("https://x.com/logo.svg"));
505 assert_eq!(bimi.authority_url.as_deref(), Some("https://x.com/vmc.pem"));
506
507 let mta = build_mta_sts(&["v=STSv1; id=20240101T000000".into()]);
508 assert!(mta.present);
509 assert_eq!(mta.id.as_deref(), Some("20240101T000000"));
510 assert_eq!(mta.verdict, PostureVerdict::Present);
511 }
512
513 #[test]
514 fn notes_flag_a_spoofable_domain() {
515 let spf = build_spf(&[]);
517 let dmarc = build_dmarc(&[]);
518 let mta = build_mta_sts(&[]);
519 let dane = build_dane(&[], &[]);
520 let notes = build_notes(&spf, &dmarc, &mta, &dane);
521 assert!(notes.iter().any(|n| n.contains("No DMARC")));
522 assert!(notes.iter().any(|n| n.contains("No SPF")));
523 assert!(notes.iter().any(|n| n.contains("MTA-STS")));
524 assert!(notes.iter().any(|n| n.contains("DANE")));
525 }
526}