1use crate::maildir;
12use mail_parser::MessageParser;
13use std::collections::BTreeMap;
14use std::io::Read;
15use std::path::{Path, PathBuf};
16
17#[derive(Debug, Clone, Default, PartialEq, Eq)]
19pub struct MailRef {
20 pub path: PathBuf,
21 pub subject: String,
22 pub from: String,
24 pub address: String,
26 pub date: i64,
28 pub refs: Vec<String>,
30}
31
32#[derive(Debug, Clone, Default)]
34pub struct MailIndex {
35 by_id: BTreeMap<String, MailRef>,
36}
37
38pub 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
45fn 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
75pub 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 pub fn build(roots: &[PathBuf], cache: &Path) -> MailIndex {
101 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 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 pub fn get(&self, id: &str) -> Option<&MailRef> {
137 self.by_id.get(&bare_id(id))
138 }
139
140 pub fn insert(&mut self, id: &str, mail: MailRef) {
142 self.by_id.insert(bare_id(id), mail);
143 }
144
145 pub fn iter(&self) -> impl Iterator<Item = (&String, &MailRef)> {
147 self.by_id.iter()
148 }
149
150 pub fn conversations(&self, seeds: &std::collections::BTreeSet<String>) -> std::collections::BTreeSet<String> {
153 self.conversations_through(seeds, |_, _| true)
154 }
155
156 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 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
183pub 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 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 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 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}