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