Skip to main content

sioul_core/
mailindex.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Messages by their Message-ID, to follow `mid:` links (RFC 2392) from a
5//! task, a note or a budget line to the message itself.
6//!
7//! Only headers are read, and what was read is kept in a cache file
8//! (`$XDG_CACHE_HOME/sioul/mail-index.tsv`), by the part of a Maildir name
9//! that never changes: a new look reads only the messages that came since.
10
11use crate::maildir;
12use mail_parser::MessageParser;
13use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::{Path, PathBuf};
16
17/// One message, as a link shows it.
18#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct MailRef {
20    pub path: PathBuf,
21    pub subject: String,
22    /// The sender's name, else address.
23    pub from: String,
24    /// The sender's address, lowercase.
25    pub address: String,
26    /// The Date header, Unix seconds.
27    pub date: i64,
28    /// The messages it answers or cites (In-Reply-To, References), bare ids.
29    pub refs: Vec<String>,
30}
31
32/// Messages by Message-ID (without angle brackets).
33#[derive(Debug, Clone, Default)]
34pub struct MailIndex {
35    by_id: BTreeMap<String, MailRef>,
36    /// Every file of each Message-ID, in the order found: a message copied
37    /// into two folders, or another message given the same ID (anyone who
38    /// knows one can), each kept.
39    paths: BTreeMap<String, Vec<PathBuf>>,
40}
41
42/// "<abc@example.org>", "mid:abc@example.org" → "abc@example.org".
43pub fn bare_id(id: &str) -> String {
44    let id = id.trim();
45    let id = id.strip_prefix("mid:").unwrap_or(id);
46    crate::notes::decode(id.trim_start_matches('<').trim_end_matches('>'))
47}
48
49/// The headers of a message file: what comes before the first empty line, at most 256 KB.
50fn headers_of(path: &Path) -> Option<Vec<u8>> {
51    let mut file = std::fs::File::open(path).ok()?;
52    let mut bytes = Vec::with_capacity(8192);
53    file.by_ref().take(256 * 1024).read_to_end(&mut bytes).ok()?;
54    let end = bytes.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 4).or_else(|| bytes.windows(2).position(|w| w == b"\n\n").map(|p| p + 2)).unwrap_or(bytes.len());
55    bytes.truncate(end);
56    Some(bytes)
57}
58
59fn read_ref(path: &Path) -> Option<(String, MailRef)> {
60    let raw = headers_of(path)?;
61    let message = MessageParser::default().parse_headers(&raw[..])?;
62    let id = bare_id(message.message_id()?);
63    let sender = message.from().and_then(|a| a.first());
64    let from = sender.map(|a| a.name().or(a.address()).unwrap_or("").to_string()).unwrap_or_default();
65    let address = sender.and_then(|a| a.address()).unwrap_or("").to_lowercase();
66    let subject = message.subject().unwrap_or("").to_string();
67    let date = message.date().map_or(0, |d| d.to_timestamp());
68    let mut refs: Vec<String> = Vec::new();
69    for list in [message.in_reply_to().as_text_list(), message.references().as_text_list()].into_iter().flatten() {
70        for id in list.iter().map(|r| bare_id(r)).filter(|r| !r.is_empty()) {
71            if !refs.contains(&id) {
72                refs.push(id);
73            }
74        }
75    }
76    Some((id, MailRef { path: path.to_path_buf(), subject, from, address, date, refs }))
77}
78
79/// Every message file under a Maildir root: its folders' `cur` and `new`.
80pub fn message_files(root: &Path) -> Vec<PathBuf> {
81    let mut out = Vec::new();
82    let mut stack = vec![root.to_path_buf()];
83    while let Some(dir) = stack.pop() {
84        let Ok(entries) = std::fs::read_dir(&dir) else { continue };
85        for entry in entries.filter_map(Result::ok) {
86            let path = entry.path();
87            let name = entry.file_name().to_string_lossy().to_string();
88            if !entry.file_type().is_ok_and(|t| t.is_dir()) {
89                continue;
90            }
91            if name == "cur" || name == "new" {
92                out.extend(std::fs::read_dir(&path).into_iter().flatten().filter_map(Result::ok).map(|e| e.path()).filter(|p| p.is_file() && !p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.'))));
93            } else if name != "tmp" {
94                stack.push(path);
95            }
96        }
97    }
98    out
99}
100
101impl MailIndex {
102    /// Indexes the Maildirs under `roots`, reading only what `cache` does not know yet;
103    /// the cache is written back.
104    pub fn build(roots: &[PathBuf], cache: &Path) -> MailIndex {
105        // unique part → (id, subject, from, address, date, refs); a line of an
106        // older form, without the address or the refs, is read from its file again.
107        let mut known: BTreeMap<String, (String, String, String, String, i64, Vec<String>)> = BTreeMap::new();
108        for line in std::fs::read_to_string(cache).unwrap_or_default().lines() {
109            let fields: Vec<&str> = line.split('\t').collect();
110            if let [unique, id, subject, from, address, date, refs] = fields[..] {
111                let refs = refs.split(' ').filter(|r| !r.is_empty()).map(str::to_string).collect();
112                known.insert(unique.to_string(), (id.to_string(), subject.to_string(), from.to_string(), address.to_string(), date.parse().unwrap_or(0), refs));
113            }
114        }
115        let mut index = MailIndex::default();
116        let mut fresh: Vec<String> = Vec::new();
117        for path in roots.iter().flat_map(|r| message_files(r)) {
118            let name = path.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default();
119            let unique = maildir::unique_part(&name).to_string();
120            let (id, mail) = match known.get(&unique) {
121                Some((id, subject, from, address, date, refs)) => (id.clone(), MailRef { path: path.clone(), subject: subject.clone(), from: from.clone(), address: address.clone(), date: *date, refs: refs.clone() }),
122                None => match read_ref(&path) {
123                    Some(found) => found,
124                    None => continue,
125                },
126            };
127            let clean = |t: &str| t.replace(['\t', '\n', '\r'], " ");
128            fresh.push(format!("{unique}\t{}\t{}\t{}\t{}\t{}\t{}", clean(&id), clean(&mail.subject), clean(&mail.from), clean(&mail.address), mail.date, clean(&mail.refs.join(" "))));
129            // The same message in two folders (Gmail's labels): the first found stays; every file is kept by its ID.
130            index.paths.entry(id.clone()).or_default().push(mail.path.clone());
131            index.by_id.entry(id).or_insert(mail);
132        }
133        if let Some(parent) = cache.parent() {
134            let _ = std::fs::create_dir_all(parent);
135        }
136        let _ = std::fs::write(cache, fresh.join("\n"));
137        index
138    }
139
140    /// The message with this Message-ID ("abc@x", "<abc@x>" or "mid:abc@x").
141    pub fn get(&self, id: &str) -> Option<&MailRef> {
142        self.by_id.get(&bare_id(id))
143    }
144
145    /// Every file of this Message-ID found here, in the order found.
146    pub fn paths(&self, id: &str) -> Vec<PathBuf> {
147        self.paths.get(&bare_id(id)).cloned().unwrap_or_default()
148    }
149
150    /// Adds a message known otherwise (a test, a message just sent).
151    pub fn insert(&mut self, id: &str, mail: MailRef) {
152        self.paths.entry(bare_id(id)).or_default().push(mail.path.clone());
153        self.by_id.insert(bare_id(id), mail);
154    }
155
156    /// Every message, by its Message-ID.
157    pub fn iter(&self) -> impl Iterator<Item = (&String, &MailRef)> {
158        self.by_id.iter()
159    }
160
161    /// The messages of the conversations `seeds` belong to: tied by the ids
162    /// they answer or cite, through any number of others (RFC 5322 §3.6.4).
163    pub fn conversations(&self, seeds: &std::collections::BTreeSet<String>) -> std::collections::BTreeSet<String> {
164        self.conversations_through(seeds, |_, _| true)
165    }
166
167    /// The messages of the conversations `seeds` belong to, as
168    /// `conversations` finds them, through the messages `keep` lets in only
169    /// (`Threads::through`).
170    pub fn conversations_through(&self, seeds: &std::collections::BTreeSet<String>, keep: impl FnMut(&str, &MailRef) -> bool) -> std::collections::BTreeSet<String> {
171        self.threads().through(seeds, keep)
172    }
173
174    /// Who cites whom, read once, to follow several projects' conversations.
175    pub fn threads(&self) -> Threads<'_> {
176        let mut cited_by: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
177        for (id, mail) in &self.by_id {
178            for cited in &mail.refs {
179                cited_by.entry(cited.as_str()).or_default().push(id.as_str());
180            }
181        }
182        Threads { index: self, cited_by }
183    }
184
185    pub fn len(&self) -> usize {
186        self.by_id.len()
187    }
188
189    pub fn is_empty(&self) -> bool {
190        self.by_id.is_empty()
191    }
192}
193
194/// The conversations of an index: who cites each id, known or only cited (`MailIndex::threads`).
195pub struct Threads<'a> {
196    index: &'a MailIndex,
197    cited_by: std::collections::HashMap<&'a str, Vec<&'a str>>,
198}
199
200impl<'a> Threads<'a> {
201    /// The messages of the conversations `seeds` belong to, through the
202    /// messages `keep` lets in only: one it leaves out is not among them and
203    /// ties nothing together (a forged message citing two conversations does
204    /// not join them). An id cited but not kept here still ties the messages
205    /// that cite it. The seeds are kept as given; `keep` is asked once at
206    /// most per message, of those reached.
207    pub fn through(&self, seeds: &std::collections::BTreeSet<String>, mut keep: impl FnMut(&str, &MailRef) -> bool) -> std::collections::BTreeSet<String> {
208        let index: &'a MailIndex = self.index;
209        let by_id = &index.by_id;
210        let mut seen: std::collections::HashSet<&str> = std::collections::HashSet::new();
211        let mut walk: Vec<&str> = Vec::new();
212        for seed in seeds {
213            if let Some((id, _)) = by_id.get_key_value(seed)
214                && seen.insert(id.as_str())
215            {
216                walk.push(id.as_str());
217            }
218        }
219        let mut out = std::collections::BTreeSet::new();
220        while let Some(at) = walk.pop() {
221            let cites = by_id.get(at).map_or(&[][..], |mail| mail.refs.as_slice());
222            if by_id.contains_key(at) {
223                out.insert(at.to_string());
224            }
225            let next: Vec<&str> = cites.iter().map(String::as_str).chain(self.cited_by.get(at).into_iter().flatten().copied()).collect();
226            for id in next {
227                if !seen.insert(id) {
228                    continue;
229                }
230                match by_id.get(id) {
231                    Some(mail) if !keep(id, mail) => {}
232                    _ => walk.push(id),
233                }
234            }
235        }
236        out
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn finds_messages_by_id_and_remembers_them() {
246        let root = std::env::temp_dir().join(format!("sioul-mailindex-{}", std::process::id()));
247        let _ = std::fs::remove_dir_all(&root);
248        let inbox = root.join("cur");
249        std::fs::create_dir_all(&inbox).unwrap();
250        std::fs::create_dir_all(root.join(".Sent").join("new")).unwrap();
251        // ":" before the flags, "!" on Windows, where ":" cannot be in a file name.
252        let named = |flags: &str| format!("1.U1-1.sioul{}2,{flags}", maildir::INFO);
253        std::fs::write(inbox.join(named("S")), "Message-ID: <abc@caf.example>\r\nFrom: CAF <ne-pas-repondre@caf.example>\r\nSubject: Votre dossier\r\nDate: Thu, 01 Oct 2026 10:00:00 +0200\r\n\r\nBody\r\n").unwrap();
254        std::fs::write(root.join(".Sent").join("new").join("2.U1-2.sioul"), "Message-ID: <sent@example.net>\nSubject: Re: Votre dossier\n\nHi\n").unwrap();
255        let cache = root.join("cache.tsv");
256        let index = MailIndex::build(std::slice::from_ref(&root), &cache);
257        assert_eq!(index.len(), 2);
258        let found = index.get("mid:abc@caf.example").unwrap();
259        assert_eq!((found.subject.as_str(), found.from.as_str()), ("Votre dossier", "CAF"));
260        assert_eq!(index.get("<sent@example.net>").unwrap().subject, "Re: Votre dossier");
261        // From the cache, the file renamed by a flag still found.
262        std::fs::rename(inbox.join(named("S")), inbox.join(named("FS"))).unwrap();
263        let again = MailIndex::build(&[root.clone()], &cache);
264        assert!(again.get("abc@caf.example").unwrap().path.ends_with(named("FS")));
265        std::fs::remove_dir_all(&root).unwrap();
266    }
267}