kumo_dmarc/
lib.rs

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