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 paths: BTreeMap<String, Vec<PathBuf>>,
40}
41
42pub 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
49fn 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
79pub 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 pub fn build(roots: &[PathBuf], cache: &Path) -> MailIndex {
105 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 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 pub fn get(&self, id: &str) -> Option<&MailRef> {
142 self.by_id.get(&bare_id(id))
143 }
144
145 pub fn paths(&self, id: &str) -> Vec<PathBuf> {
147 self.paths.get(&bare_id(id)).cloned().unwrap_or_default()
148 }
149
150 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 pub fn iter(&self) -> impl Iterator<Item = (&String, &MailRef)> {
158 self.by_id.iter()
159 }
160
161 pub fn conversations(&self, seeds: &std::collections::BTreeSet<String>) -> std::collections::BTreeSet<String> {
164 self.conversations_through(seeds, |_, _| true)
165 }
166
167 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 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
194pub 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 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 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 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}