kumo_dmarc/
lib.rs

1#![allow(dead_code)]
2
3use crate::types::record::Record;
4pub use crate::types::results::{Disposition, DispositionWithContext};
5use bstr::BString;
6use dns_resolver::Resolver;
7use std::collections::BTreeMap;
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    /// The results of the DKIM part of the checks
28    pub dkim: Vec<BTreeMap<String, BString>>,
29}
30
31impl CheckHostParams {
32    pub async fn check(self, resolver: &dyn Resolver) -> DispositionWithContext {
33        let Self {
34            from_domain,
35            mail_from_domain,
36            dkim,
37        } = self;
38
39        match DmarcContext::new(
40            &from_domain,
41            mail_from_domain.as_ref().map(|x| x.as_str()),
42            &dkim[..],
43        ) {
44            Ok(cx) => cx.check(resolver).await,
45            Err(result) => result,
46        }
47    }
48}
49
50pub(crate) enum SenderDomainAlignment {
51    /// Sender domain is an exact match to the dmarc record
52    Exact,
53
54    /// Sender domain has no exact matching dmarc record
55    /// but its organizational domain does
56    OrganizationalDomain,
57}
58
59pub(crate) enum DmarcRecordResolution {
60    /// DNS could not be resolved at this time
61    TempError,
62
63    /// DNS was resolved, but no DMARC record was found
64    PermError,
65
66    /// DNS was resolved, and DMARC record was found
67    Records(Vec<Record>),
68}
69
70impl From<DmarcRecordResolution> for Disposition {
71    fn from(value: DmarcRecordResolution) -> Self {
72        match value {
73            DmarcRecordResolution::TempError => Disposition::TempError,
74            DmarcRecordResolution::PermError => Disposition::PermError,
75            DmarcRecordResolution::Records(_) => {
76                panic!("records must be parsed before being used in disposition")
77            }
78        }
79    }
80}
81
82struct DmarcContext<'a> {
83    pub(crate) from_domain: &'a str,
84    pub(crate) mail_from_domain: Option<&'a str>,
85    pub(crate) now: SystemTime,
86    pub(crate) dkim: &'a [BTreeMap<String, BString>],
87}
88
89impl<'a> DmarcContext<'a> {
90    /// Create a new evaluation context.
91    ///
92    /// - `from_domain` is the domain of the "From:" header
93    /// - `mail_from_domain` is the domain portion of the "MAIL FROM" identity
94    /// - `client_ip` is the IP address of the SMTP client that is emitting the mail
95    fn new(
96        from_domain: &'a str,
97        mail_from_domain: Option<&'a str>,
98        dkim: &'a [BTreeMap<String, BString>],
99    ) -> Result<Self, DispositionWithContext> {
100        Ok(Self {
101            from_domain,
102            mail_from_domain,
103            now: SystemTime::now(),
104            dkim,
105        })
106    }
107
108    pub async fn check(&self, resolver: &dyn Resolver) -> DispositionWithContext {
109        match fetch_dmarc_records(&format!("_dmarc.{}", self.from_domain), resolver).await {
110            DmarcRecordResolution::Records(records) => {
111                for record in records {
112                    return record.evaluate(self, SenderDomainAlignment::Exact).await;
113                }
114            }
115            x => {
116                if let Some(organizational_domain) = psl::domain_str(self.from_domain) {
117                    if organizational_domain != self.from_domain {
118                        let address = format!("_dmarc.{}", organizational_domain);
119                        match fetch_dmarc_records(&address, resolver).await {
120                            DmarcRecordResolution::TempError => {
121                                return DispositionWithContext {
122                                    result: Disposition::TempError,
123                                    context: format!(
124                                        "DNS records could not be resolved for {}",
125                                        address
126                                    ),
127                                }
128                            }
129                            DmarcRecordResolution::PermError => {
130                                return DispositionWithContext {
131                                    result: Disposition::PermError,
132                                    context: format!("no DMARC records found for {}", address),
133                                }
134                            }
135                            DmarcRecordResolution::Records(records) => {
136                                for record in records {
137                                    return record
138                                        .evaluate(self, SenderDomainAlignment::OrganizationalDomain)
139                                        .await;
140                                }
141                            }
142                        }
143                    } else {
144                        return DispositionWithContext {
145                            result: x.into(),
146                            context: format!("no DMARC records found for {}", &self.from_domain),
147                        };
148                    }
149                }
150            }
151        }
152
153        DispositionWithContext {
154            result: Disposition::None,
155            context: format!("no DMARC records found for {}", &self.from_domain),
156        }
157    }
158}
159
160pub(crate) async fn fetch_dmarc_records(
161    address: &str,
162    resolver: &dyn Resolver,
163) -> DmarcRecordResolution {
164    let initial_txt = match resolver.resolve_txt(address).await {
165        Ok(answer) => {
166            if answer.records.is_empty() || answer.nxdomain {
167                return DmarcRecordResolution::PermError;
168            } else {
169                eprintln!("answer: {:?}", answer);
170                answer.as_txt()
171            }
172        }
173        Err(_) => {
174            return DmarcRecordResolution::TempError;
175        }
176    };
177
178    let mut records = vec![];
179
180    // TXT records can contain all sorts of stuff, let's walk through
181    // the set that we retrieved and take the first one that parses
182    for txt in initial_txt {
183        if txt.starts_with("v=DMARC1;") {
184            if let Ok(record) = Record::from_str(&txt) {
185                records.push(record);
186            }
187        }
188    }
189
190    if records.is_empty() {
191        return DmarcRecordResolution::PermError;
192    }
193
194    DmarcRecordResolution::Records(records)
195}