kumo_dmarc/
lib.rs

1#![allow(dead_code)]
2
3use crate::types::record::Record;
4use crate::types::results::DmarcResultWithContext;
5use dns_resolver::Resolver;
6use std::collections::BTreeMap;
7use std::net::IpAddr;
8use std::str::FromStr;
9use std::time::SystemTime;
10
11pub use types::results::DmarcResult;
12
13mod types;
14
15#[cfg(test)]
16mod tests;
17
18pub struct CheckHostParams {
19    /// Domain of the sender in the "From:"
20    pub from_domain: String,
21
22    /// Domain that provides the sought-after authorization information.
23    ///
24    /// The "MAIL FROM" email address if available.
25    pub mail_from_domain: Option<String>,
26
27    /// IP address of the SMTP client that is emitting the mail (v4 or v6).
28    pub client_ip: IpAddr,
29
30    /// The results of the DKIM part of the checks
31    pub dkim: Vec<BTreeMap<String, String>>,
32}
33
34impl CheckHostParams {
35    pub async fn check(self, resolver: &dyn Resolver) -> DmarcResultWithContext {
36        let Self {
37            from_domain,
38            mail_from_domain,
39            client_ip,
40            dkim,
41        } = self;
42
43        match DmarcContext::new(
44            &from_domain,
45            mail_from_domain.as_ref().map(|x| x.as_str()),
46            client_ip,
47            &dkim[..],
48        ) {
49            Ok(cx) => cx.check(resolver).await,
50            Err(result) => result,
51        }
52    }
53}
54
55struct DmarcContext<'a> {
56    pub(crate) from_domain: &'a str,
57    pub(crate) mail_from_domain: Option<&'a str>,
58    pub(crate) client_ip: IpAddr,
59    pub(crate) now: SystemTime,
60    pub(crate) dkim: &'a [BTreeMap<String, String>],
61}
62
63impl<'a> DmarcContext<'a> {
64    /// Create a new evaluation context.
65    ///
66    /// - `from_domain` is the domain of the "From:" header
67    /// - `mail_from_domain` is the domain portion of the "MAIL FROM" identity
68    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
69    fn new(
70        from_domain: &'a str,
71        mail_from_domain: Option<&'a str>,
72        client_ip: IpAddr,
73        dkim: &'a [BTreeMap<String, String>],
74    ) -> Result<Self, DmarcResultWithContext> {
75        Ok(Self {
76            from_domain,
77            mail_from_domain,
78            client_ip,
79            now: SystemTime::now(),
80            dkim,
81        })
82    }
83
84    pub async fn check(&self, resolver: &dyn Resolver) -> DmarcResultWithContext {
85        let initial_txt = match resolver.resolve_txt(self.from_domain).await {
86            Ok(answer) => {
87                if answer.records.is_empty() || answer.nxdomain {
88                    return DmarcResultWithContext {
89                        result: DmarcResult::Fail,
90                        context: format!("no DMARC records found for {}", &self.from_domain),
91                    };
92                } else {
93                    answer.as_txt()
94                }
95            }
96            Err(err) => {
97                return DmarcResultWithContext {
98                    result: DmarcResult::Fail,
99                    context: format!("{err}"),
100                };
101            }
102        };
103
104        // TXT records can contain all sorts of stuff, let's walk through
105        // the set that we retrieved and take the first one that parses
106        for txt in initial_txt {
107            if txt.starts_with("v=DMARC1") {
108                match Record::from_str(&txt) {
109                    Ok(record) => {
110                        return record.evaluate(self, resolver).await;
111                    }
112                    Err(err) => {
113                        return DmarcResultWithContext {
114                            result: DmarcResult::Fail,
115                            context: format!("failed to parse DMARC record: {err}"),
116                        };
117                    }
118                }
119            }
120        }
121        DmarcResultWithContext {
122            result: DmarcResult::Fail,
123            context: format!("no DMARC records found for {}", &self.from_domain),
124        }
125    }
126}