Skip to main content

sioul_sync/
verify.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Sioul's own checks of who sent a message: SPF, DKIM, DMARC, ARC and the
5//! reverse DNS of the server that handed it to your provider, done when the
6//! message is fetched, through the system's DNS (Stalwart's `mail-auth`).
7//!
8//! Some providers record no checks (a shared host may write only a spam score), and
9//! those that do differ. The results are written on top of the stored message
10//! as an Authentication-Results header (RFC 8601) under Sioul's own name for
11//! this installation (`config::sioul_authserv_id`), which the Porch trusts
12//! first. Checking when the message arrives also matters: DKIM keys rotate, and
13//! some signatures expire within days.
14
15use mail_auth::common::headers::HeaderWriter;
16use mail_auth::spf::verify::SpfParameters;
17use mail_auth::dmarc::verify::DmarcParameters;
18use mail_auth::{AuthenticatedMessage, AuthenticationResults, DmarcResult, MessageAuthenticator};
19use sioul_core::config::sioul_authserv_id;
20use sioul_core::headers::RawHeaders;
21use sioul_core::trust;
22use std::path::Path;
23use std::time::Duration;
24use tokio::time::timeout;
25
26/// The longest one message's checks may take: DNS can be slow, never blocking.
27const CHECKS: Duration = Duration::from_secs(20);
28
29pub struct Verifier {
30    authenticator: MessageAuthenticator,
31    id: String,
32}
33
34impl Verifier {
35    pub fn new() -> Option<Verifier> {
36        let authenticator = MessageAuthenticator::new_system_conf().ok()?;
37        Some(Verifier { authenticator, id: sioul_authserv_id() })
38    }
39
40    /// For checking many messages in one run (the spam filter's training
41    /// corpus, `sioul_learn::corpus`): the system's DNS, each question asked
42    /// once and given `timeout`, each answer kept for the run (the
43    /// resolver's cache, the names that do not exist too).
44    pub fn quick(timeout: Duration) -> Option<Verifier> {
45        let (config, mut options) = mail_auth::hickory_resolver::system_conf::read_system_conf().ok()?;
46        options.timeout = timeout;
47        options.attempts = 1;
48        options.cache_size = options.cache_size.max(16_384);
49        let authenticator = MessageAuthenticator::new(config, options).ok()?;
50        Some(Verifier { authenticator, id: sioul_authserv_id() })
51    }
52
53    /// Sioul's results for a message, as `stamp` writes them on top of it
54    /// ("Authentication-Results: sioul-….invalid; dkim=pass …"); none when
55    /// it cannot be read. `whole` false: its header block alone (a message
56    /// too large to fetch whole), so what needs the body is left unsaid
57    /// (DKIM, ARC), and DMARC is said only when it passes (by SPF: a
58    /// signature left unchecked might have passed it). No time limit: the
59    /// caller's.
60    pub async fn results_of(&self, raw: &[u8], whole: bool) -> Option<String> {
61        self.results(raw, whole).await
62    }
63
64    /// Whether this verifier's DNS answers at all, asked for a name under
65    /// `domain` made up for the question, so that no cache holds it: a
66    /// server saying it does not exist answers too.
67    pub async fn answers(&self, domain: &str) -> bool {
68        let made_up = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map_or(0, |d| d.as_nanos());
69        let name = format!("sioul-{made_up:x}.{}.", domain.trim_end_matches('.'));
70        match timeout(CHECKS, self.authenticator.resolver().lookup_ip(name.as_str())).await {
71            Ok(Ok(_)) => true,
72            Ok(Err(e)) => e.is_no_records_found(),
73            Err(_) => false,
74        }
75    }
76
77    /// The message with Sioul's results on top; unchanged if it cannot be read,
78    /// is already stamped, or the checks do not finish in time (a later pass retries).
79    pub async fn stamp(&self, raw: &[u8]) -> Vec<u8> {
80        if self.stamped(raw) {
81            return raw.to_vec();
82        }
83        match timeout(CHECKS, self.results(raw, true)).await {
84            Ok(Some(header)) => [header.as_bytes(), raw].concat(),
85            _ => raw.to_vec(),
86        }
87    }
88
89    /// Whether the message already carries Sioul's results.
90    pub fn stamped(&self, raw: &[u8]) -> bool {
91        let head = &raw[..raw.len().min(16 * 1024)];
92        let needle = format!("Authentication-Results: {}", self.id);
93        String::from_utf8_lossy(head).contains(&needle)
94    }
95
96    async fn results(&self, raw: &[u8], whole: bool) -> Option<String> {
97        let message = AuthenticatedMessage::parse(raw)?;
98        let headers = RawHeaders::parse(raw);
99        // Without the body, no signature can be checked: none is said.
100        let dkim = if whole { self.authenticator.verify_dkim(&message).await } else { Vec::new() };
101        let arc = if whole { Some(self.authenticator.verify_arc(&message).await) } else { None };
102        let entry = trust::boundary(&headers);
103        let mail_from = trust::mail_from(&headers).unwrap_or_default();
104        let mail_from_domain = mail_from.rsplit_once('@').map_or(String::new(), |(_, d)| d.to_ascii_lowercase());
105        let results = AuthenticationResults::new(&self.id).with_dkim_results(&dkim, message.from());
106        // Mail that never came from outside (your provider's own notices, a message
107        // to yourself delivered inside one provider) has no server to check SPF on,
108        // so DMARC cannot judge it: only its signatures are recorded.
109        let Some(e) = entry else { return Some(results.to_header()) };
110        let spf = self.authenticator.verify_spf(SpfParameters::verify_mail_from(e.ip, &e.helo, &e.by, &mail_from)).await;
111        let spf_domain = if mail_from_domain.is_empty() { e.helo.clone() } else { mail_from_domain };
112        let dmarc = self.authenticator.verify_dmarc(DmarcParameters::new(&message, &dkim, &spf_domain, &spf)).await;
113        let iprev = self.authenticator.verify_iprev(e.ip).await;
114        let mut results = results.with_spf_mailfrom_result(&spf, e.ip, &mail_from, &e.helo);
115        if let Some(arc) = &arc {
116            results = results.with_arc_result(arc, e.ip);
117        }
118        results = results.with_iprev_result(&iprev, e.ip);
119        if whole || dmarc.result() == DmarcResult::Pass {
120            results = results.with_dmarc_result(&dmarc);
121        }
122        Some(results.to_header())
123    }
124
125    /// The message without Sioul's results, to check it again: they are the first header.
126    pub fn unstamp(&self, raw: &[u8]) -> Vec<u8> {
127        if !raw.starts_with(format!("Authentication-Results: {}", self.id).as_bytes()) {
128            return raw.to_vec();
129        }
130        let mut end = 0;
131        for line in raw.split_inclusive(|b| *b == b'\n') {
132            if end > 0 && !line.starts_with(b" ") && !line.starts_with(b"\t") {
133                break;
134            }
135            end += line.len();
136        }
137        raw[end..].to_vec()
138    }
139}
140
141/// Checks every message of a Maildir that Sioul has not checked yet (with
142/// `again`, every message, its earlier results replaced), and writes the
143/// results on top of it, in place. Returns how many were checked.
144pub fn verify_folder(root: &Path, again: bool) -> usize {
145    let Some(verifier) = Verifier::new() else { return 0 };
146    let Ok(runtime) = tokio::runtime::Builder::new_current_thread().enable_all().build() else { return 0 };
147    runtime.block_on(async {
148        let mut checked = 0;
149        for sub in ["new", "cur"] {
150            let Ok(entries) = std::fs::read_dir(root.join(sub)) else { continue };
151            for path in entries.filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_file()) {
152                let Ok(raw) = std::fs::read(&path) else { continue };
153                let raw = if again { verifier.unstamp(&raw) } else { raw };
154                if verifier.stamped(&raw) {
155                    continue;
156                }
157                let stamped = verifier.stamp(&raw).await;
158                if stamped.len() == raw.len() {
159                    continue;
160                }
161                // Written beside, then moved over: the file keeps its name, so its flags and origin stay.
162                let temporary = root.join("tmp").join(path.file_name().unwrap_or_default());
163                let written = std::fs::create_dir_all(root.join("tmp")).and_then(|()| write_private(&temporary, &stamped));
164                let modified = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
165                if written.is_ok() && std::fs::rename(&temporary, &path).is_ok() {
166                    if let (Some(time), Ok(file)) = (modified, std::fs::File::options().write(true).open(&path)) {
167                        let _ = file.set_modified(time);
168                    }
169                    checked += 1;
170                }
171            }
172        }
173        checked
174    })
175}
176
177/// Mail written yours alone, as it was fetched (`maildir::store`): not
178/// readable by the other accounts of a shared computer, whatever the umask.
179fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> {
180    use std::io::Write;
181    let mut options = std::fs::OpenOptions::new();
182    options.write(true).create(true).truncate(true);
183    #[cfg(unix)]
184    {
185        use std::os::unix::fs::OpenOptionsExt;
186        options.mode(0o600);
187    }
188    let mut file = options.open(path)?;
189    file.write_all(bytes)?;
190    file.sync_all()
191}