1mod batch;
2mod json;
3mod query;
4
5pub use json::{ShowMessage, ThreadNode};
6pub use query::{and, escape_query_value};
7
8use crate::error::{Error, Result};
9use crate::paths::MailPaths;
10use ecr_core::message::{
11 Address, Body, BodyFormat, Message, MessageId, Part, PartId, Query, TagOp, Thread, ThreadId,
12 ThreadSummary,
13};
14use ecr_core::revision::Revision;
15use std::collections::HashMap;
16use std::io::{BufRead, BufReader};
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19use tokio::process::Command;
20use tokio::sync::Mutex;
21
22#[derive(Debug, Clone, serde::Serialize)]
24pub struct MailingList {
25 pub id: String,
27 pub name: String,
29 pub count: u32,
30}
31
32fn read_list_id(path: &Path) -> Option<String> {
35 let file = std::fs::File::open(path).ok()?;
36 let mut reader = BufReader::new(file);
37 let mut line = String::new();
38 let mut found: Option<String> = None;
39
40 loop {
41 line.clear();
42 let read = reader.read_line(&mut line).ok()?;
43 if read == 0 {
44 break;
45 }
46 let trimmed = line.trim_end_matches(['\r', '\n']);
47 if trimmed.is_empty() {
48 break;
49 }
50
51 if let Some(value) = found.as_mut() {
53 if trimmed.starts_with([' ', '\t']) {
54 value.push(' ');
55 value.push_str(trimmed.trim());
56 continue;
57 }
58 break;
59 }
60
61 if let Some(rest) = trimmed
62 .get(..8)
63 .filter(|head| head.eq_ignore_ascii_case("list-id:"))
64 .map(|_| &trimmed[8..])
65 {
66 found = Some(rest.trim().to_string());
67 }
68 }
69
70 found.filter(|v| !v.is_empty())
71}
72
73fn split_list_id(raw: &str) -> (String, String) {
76 match (raw.rfind('<'), raw.rfind('>')) {
77 (Some(open), Some(close)) if close > open => {
78 let id = raw[open + 1..close].trim().to_string();
79 let name = raw[..open].trim().trim_matches('"').trim().to_string();
80 let name = if name.is_empty() { id.clone() } else { name };
81 (id, name)
82 }
83 _ => {
84 let id = raw.trim().trim_matches('"').to_string();
85 (id.clone(), id)
86 }
87 }
88}
89
90pub struct Notmuch {
91 paths: Arc<MailPaths>,
92 write_lock: Mutex<()>,
93 parsed: crate::cache::FileCache<Arc<crate::mime::ParsedMessage>>,
96 files: crate::cache::FileCache<PathBuf>,
97}
98
99impl Notmuch {
100 pub fn new(paths: Arc<MailPaths>) -> Self {
101 Self {
102 paths,
103 write_lock: Mutex::new(()),
104 parsed: crate::cache::FileCache::new(256),
105 files: crate::cache::FileCache::new(2048),
106 }
107 }
108
109 pub fn paths(&self) -> &MailPaths {
110 &self.paths
111 }
112
113 fn command(&self) -> Command {
114 let mut command = Command::new(&self.paths.binaries.notmuch);
115 if let Some(config) = &self.paths.notmuch.path {
116 command.arg(format!("--config={}", config.display()));
117 command.env("NOTMUCH_CONFIG", config);
118 }
119 command
120 }
121
122 async fn run(&self, args: &[&str]) -> Result<String> {
123 let output = self
124 .command()
125 .args(args)
126 .output()
127 .await
128 .map_err(|e| match e.kind() {
129 std::io::ErrorKind::NotFound => Error::ToolMissing {
130 tool: crate::tools::NOTMUCH,
131 },
132 _ => Error::Io(e),
133 })?;
134
135 if !output.status.success() {
136 return Err(Error::ToolFailed {
137 tool: crate::tools::NOTMUCH,
138 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
139 });
140 }
141 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
142 }
143
144 async fn run_json<T: serde::de::DeserializeOwned>(&self, args: &[&str]) -> Result<T> {
145 let stdout = self.run(args).await?;
146 serde_json::from_str(&stdout).map_err(|e| Error::ToolFailed {
147 tool: crate::tools::NOTMUCH,
148 stderr: format!("could not parse `notmuch {}` output: {e}", args.join(" ")),
149 })
150 }
151
152 pub async fn revision(&self) -> Result<Revision> {
153 let stdout = self.run(&["count", "--lastmod", "*"]).await?;
154 parse_lastmod(&stdout)
155 }
156
157 pub async fn count(&self, query: &Query) -> Result<usize> {
158 let stdout = self.run(&["count", query.effective_text()]).await?;
159 stdout.trim().parse().map_err(|_| Error::ToolFailed {
160 tool: crate::tools::NOTMUCH,
161 stderr: format!("`notmuch count` returned {stdout:?}"),
162 })
163 }
164
165 pub async fn count_batch(&self, queries: &[String]) -> Result<Vec<u64>> {
176 use tokio::io::AsyncWriteExt;
177
178 if queries.is_empty() {
179 return Ok(Vec::new());
180 }
181
182 const NOTHING: &str = "tag:__ecr_matches_nothing__";
183 let batch: String = queries
184 .iter()
185 .map(|q| {
186 let line = q.trim();
187 if line.is_empty() {
188 NOTHING
189 } else {
190 line
191 }
192 })
193 .collect::<Vec<_>>()
194 .join("\n");
195
196 let mut child = self
197 .command()
198 .args(["count", "--batch"])
199 .stdin(std::process::Stdio::piped())
200 .stdout(std::process::Stdio::piped())
201 .stderr(std::process::Stdio::piped())
202 .spawn()
203 .map_err(|e| match e.kind() {
204 std::io::ErrorKind::NotFound => Error::ToolMissing {
205 tool: crate::tools::NOTMUCH,
206 },
207 _ => Error::Io(e),
208 })?;
209
210 if let Some(mut stdin) = child.stdin.take() {
211 stdin.write_all(batch.as_bytes()).await?;
212 stdin.write_all(b"\n").await?;
213 stdin.shutdown().await?;
214 }
215
216 let output = child.wait_with_output().await?;
217 if !output.status.success() {
218 return Err(Error::ToolFailed {
219 tool: crate::tools::NOTMUCH,
220 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
221 });
222 }
223
224 let stdout = String::from_utf8_lossy(&output.stdout);
225 let counts: Vec<u64> = stdout
226 .lines()
227 .filter(|line| !line.trim().is_empty())
228 .map(|line| line.trim().parse().unwrap_or(0))
229 .collect();
230
231 if counts.len() != queries.len() {
233 return Err(Error::ToolFailed {
234 tool: crate::tools::NOTMUCH,
235 stderr: format!(
236 "`notmuch count --batch` answered {} of {} queries",
237 counts.len(),
238 queries.len()
239 ),
240 });
241 }
242
243 Ok(counts)
244 }
245
246 pub async fn search_threads(&self, query: &Query) -> Result<Vec<ThreadSummary>> {
247 let limit = format!("--limit={}", query.limit);
248 let offset = format!("--offset={}", query.offset);
249 let items: Vec<json::SearchItem> = self
250 .run_json(&[
251 "search",
252 "--format=json",
253 "--output=summary",
254 "--sort=newest-first",
255 &limit,
256 &offset,
257 query.effective_text(),
258 ])
259 .await?;
260
261 Ok(items
262 .into_iter()
263 .map(json::SearchItem::into_summary)
264 .collect())
265 }
266
267 pub async fn thread(&self, id: &ThreadId) -> Result<Thread> {
268 let messages = self.show(&id.query()).await?;
269 let subject = messages
270 .first()
271 .map(|m| m.subject.clone())
272 .unwrap_or_default();
273
274 Ok(Thread {
275 id: id.clone(),
276 subject,
277 messages,
278 })
279 }
280
281 pub async fn message(&self, id: &MessageId) -> Result<Message> {
282 self.show(&id.query())
283 .await?
284 .into_iter()
285 .next()
286 .ok_or_else(|| Error::MessageNotFound { id: id.to_string() })
287 }
288
289 async fn show(&self, query: &str) -> Result<Vec<Message>> {
290 let output: json::ShowOutput = self
291 .run_json(&[
292 "show",
293 "--format=json",
294 "--body=false",
295 "--entire-thread=true",
296 query,
297 ])
298 .await?;
299
300 let mut messages: Vec<Message> = output
301 .flatten()
302 .into_iter()
303 .filter_map(|m| m.into_message())
304 .collect();
305
306 messages.sort_by_key(|m| m.timestamp);
307 Ok(messages)
308 }
309
310 pub async fn message_file(&self, id: &MessageId) -> Result<PathBuf> {
311 if let Some(cached) = self.files.get(id.as_str(), None) {
314 if cached.is_file() {
315 return Ok(cached);
316 }
317 }
318
319 let stdout = self
320 .run(&["search", "--output=files", "--format=text", &id.query()])
321 .await?;
322
323 let path = stdout
324 .lines()
325 .map(|l| PathBuf::from(l.trim()))
326 .find(|p| p.is_file())
327 .ok_or_else(|| Error::MessageNotFound { id: id.to_string() })?;
328
329 self.files.insert(id.0.clone(), None, path.clone());
330 Ok(path)
331 }
332
333 pub async fn parsed(&self, id: &MessageId) -> Result<Arc<crate::mime::ParsedMessage>> {
334 let path = self.message_file(id).await?;
335 let modified = crate::cache::modified_at(&path);
336 let key = path.to_string_lossy().into_owned();
337
338 if let Some(cached) = self.parsed.get(&key, modified) {
339 return Ok(cached);
340 }
341
342 let raw = tokio::fs::read(&path).await?;
343 let parsed = Arc::new(crate::mime::parse(id.as_str(), &raw)?);
344
345 self.parsed.insert(key, modified, Arc::clone(&parsed));
346 Ok(parsed)
347 }
348
349 pub async fn message_with_parts(&self, id: &MessageId) -> Result<Message> {
350 let mut message = self.message(id).await?;
351 message.parts = self.parsed(id).await?.parts();
352 Ok(message)
353 }
354
355 pub async fn body(
356 &self,
357 id: &MessageId,
358 format: BodyFormat,
359 allow_remote_resources: bool,
360 ) -> Result<Body> {
361 let parsed = self.parsed(id).await?;
362 let ctx = crate::mime::SanitizeContext::new(
363 format!("/api/v1/messages/{id}/parts/"),
364 allow_remote_resources,
365 );
366 Ok(parsed.body(format, &ctx))
367 }
368
369 pub async fn part(&self, id: &MessageId, part: &PartId) -> Result<Part> {
370 self.parsed(id)
371 .await?
372 .part(part)
373 .ok_or_else(|| Error::PartNotFound {
374 id: id.to_string(),
375 part: part.0,
376 })
377 }
378
379 pub async fn address_book(&self, limit: usize) -> Result<crate::address::AddressBook> {
382 use crate::address::{AddressBook, Source};
383
384 let mut book = AddressBook::new();
385
386 let recipients = self
387 .run(&[
388 "address",
389 "--output=recipients",
390 "--deduplicate=address",
391 "tag:sent or tag:draft",
392 ])
393 .await
394 .unwrap_or_default();
395 book.add_lines(&recipients, Source::Recipient);
396
397 let senders = self
398 .run(&["address", "--output=sender", "--deduplicate=address", "*"])
399 .await
400 .unwrap_or_default();
401 book.add_lines(&senders, Source::Sender);
402
403 let _ = limit;
404 Ok(book)
405 }
406
407 pub async fn mailing_lists(&self, scan: usize) -> Result<Vec<MailingList>> {
415 let files = self
416 .run(&[
417 "search",
418 "--output=files",
419 "--limit",
420 &scan.to_string(),
421 "*",
422 ])
423 .await
424 .unwrap_or_default();
425
426 let paths: Vec<PathBuf> = files.lines().map(PathBuf::from).collect();
427
428 let lists = tokio::task::spawn_blocking(move || {
430 let mut seen: HashMap<String, MailingList> = HashMap::new();
431
432 for path in paths {
433 let Some(raw) = read_list_id(&path) else {
434 continue;
435 };
436 let (id, name) = split_list_id(&raw);
437 let entry =
438 seen.entry(id.clone())
439 .or_insert_with(|| MailingList { id, name, count: 0 });
440 entry.count += 1;
441 }
442
443 let mut lists: Vec<MailingList> = seen.into_values().collect();
444 lists.sort_by(|a, b| b.count.cmp(&a.count).then_with(|| a.name.cmp(&b.name)));
445 lists
446 })
447 .await
448 .map_err(|e| Error::ToolFailed {
449 tool: crate::tools::NOTMUCH,
450 stderr: format!("scanning for mailing lists panicked: {e}"),
451 })?;
452
453 Ok(lists)
454 }
455
456 pub async fn indexes_list_id(&self) -> bool {
460 self.run(&["config", "get", "index.header.List"])
461 .await
462 .map(|value| value.trim().eq_ignore_ascii_case("List-Id"))
463 .unwrap_or(false)
464 }
465
466 pub async fn tags(&self) -> Result<Vec<String>> {
468 let stdout = self.run(&["search", "--output=tags", "*"]).await?;
469 Ok(stdout
470 .lines()
471 .map(|l| l.trim().to_string())
472 .filter(|l| !l.is_empty())
473 .collect())
474 }
475
476 pub async fn tag(&self, ops: &[TagOp]) -> Result<Revision> {
477 self.tag_batch(&batch::build(ops)?).await
478 }
479
480 pub async fn tag_batch(&self, batch: &str) -> Result<Revision> {
481 use tokio::io::AsyncWriteExt;
482
483 if batch.trim().is_empty() {
484 return self.revision().await;
485 }
486
487 let _guard = self.write_lock.lock().await;
488
489 let mut child = self
490 .command()
491 .args(["tag", "--batch"])
492 .stdin(std::process::Stdio::piped())
493 .stdout(std::process::Stdio::piped())
494 .stderr(std::process::Stdio::piped())
495 .spawn()
496 .map_err(|e| match e.kind() {
497 std::io::ErrorKind::NotFound => Error::ToolMissing {
498 tool: crate::tools::NOTMUCH,
499 },
500 _ => Error::Io(e),
501 })?;
502
503 if let Some(mut stdin) = child.stdin.take() {
504 stdin.write_all(batch.as_bytes()).await?;
505 stdin.shutdown().await?;
506 }
507
508 let output = child.wait_with_output().await?;
509 if !output.status.success() {
510 return Err(Error::ToolFailed {
511 tool: crate::tools::NOTMUCH,
512 stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(),
513 });
514 }
515
516 drop(_guard);
517 self.revision().await
518 }
519
520 pub async fn index_new(&self) -> Result<Revision> {
521 let _guard = self.write_lock.lock().await;
522 self.run(&["new", "--quiet"]).await?;
523 drop(_guard);
524 self.revision().await
525 }
526}
527
528fn parse_lastmod(stdout: &str) -> Result<Revision> {
529 let line = stdout.trim();
530 let mut fields = line.split_whitespace();
531 let (_count, uuid, lastmod) = (fields.next(), fields.next(), fields.next());
532
533 match (uuid, lastmod) {
534 (Some(uuid), Some(lastmod)) => lastmod
535 .parse()
536 .map(|lastmod| Revision::new(uuid, lastmod))
537 .map_err(|_| Error::ToolFailed {
538 tool: crate::tools::NOTMUCH,
539 stderr: format!("unparseable lastmod in {line:?}"),
540 }),
541 _ => Err(Error::ToolFailed {
542 tool: crate::tools::NOTMUCH,
543 stderr: format!("unexpected `notmuch count --lastmod` output: {line:?}"),
544 }),
545 }
546}
547
548pub fn parse_address_list(raw: &str) -> Vec<Address> {
549 let mut out = Vec::new();
550 let mut current = String::new();
551 let mut in_quotes = false;
552 let mut in_angle = false;
553
554 for ch in raw.chars() {
555 match ch {
556 '"' => {
557 in_quotes = !in_quotes;
558 current.push(ch);
559 }
560 '<' if !in_quotes => {
561 in_angle = true;
562 current.push(ch);
563 }
564 '>' if !in_quotes => {
565 in_angle = false;
566 current.push(ch);
567 }
568 ',' if !in_quotes && !in_angle => {
569 push_address(&mut out, ¤t);
570 current.clear();
571 }
572 _ => current.push(ch),
573 }
574 }
575 push_address(&mut out, ¤t);
576 out
577}
578
579fn push_address(out: &mut Vec<Address>, raw: &str) {
580 let raw = raw.trim();
581 if raw.is_empty() {
582 return;
583 }
584
585 match (raw.find('<'), raw.rfind('>')) {
586 (Some(start), Some(end)) if end > start => {
587 let name = raw[..start].trim().trim_matches('"').trim();
588 let email = raw[start + 1..end].trim();
589 if !email.is_empty() {
590 out.push(Address::new(
591 (!name.is_empty()).then(|| name.to_string()),
592 email,
593 ));
594 }
595 }
596 _ => out.push(Address::new(None, raw)),
597 }
598}
599
600#[cfg(test)]
601mod tests {
602 use super::*;
603
604 #[test]
605 fn parses_the_live_lastmod_line() {
606 let rev = parse_lastmod("45865\tc92ee515-acf5-452e-a148-5941a7e9852f\t227965\n").unwrap();
607 assert_eq!(rev.uuid, "c92ee515-acf5-452e-a148-5941a7e9852f");
608 assert_eq!(rev.lastmod, 227965);
609 }
610
611 #[test]
612 fn rejects_unexpected_lastmod_output() {
613 assert!(parse_lastmod("").is_err());
614 assert!(parse_lastmod("45865").is_err());
615 assert!(parse_lastmod("45865\tuuid\tnot-a-number").is_err());
616 }
617
618 #[test]
619 fn parses_a_plain_address() {
620 let addrs = parse_address_list("alice@example.com");
621 assert_eq!(addrs.len(), 1);
622 assert_eq!(addrs[0].email, "alice@example.com");
623 assert_eq!(addrs[0].name, None);
624 }
625
626 #[test]
627 fn parses_a_named_address() {
628 let addrs = parse_address_list("Google <no-reply@accounts.google.com>");
629 assert_eq!(addrs[0].name.as_deref(), Some("Google"));
630 assert_eq!(addrs[0].email, "no-reply@accounts.google.com");
631 }
632
633 #[test]
634 fn a_comma_inside_a_quoted_name_does_not_split_the_list() {
635 let addrs = parse_address_list("\"Doe, Jane\" <a@b.c>, Other <d@e.f>");
636 assert_eq!(addrs.len(), 2);
637 assert_eq!(addrs[0].name.as_deref(), Some("Doe, Jane"));
638 assert_eq!(addrs[0].email, "a@b.c");
639 assert_eq!(addrs[1].email, "d@e.f");
640 }
641
642 #[test]
643 fn an_empty_header_yields_no_addresses() {
644 assert!(parse_address_list("").is_empty());
645 assert!(parse_address_list(" , ").is_empty());
646 }
647
648 #[test]
649 fn a_list_id_splits_into_its_name_and_its_id() {
650 let (id, name) = split_list_id("Emacs development <emacs-devel.gnu.org>");
651 assert_eq!(id, "emacs-devel.gnu.org");
652 assert_eq!(name, "Emacs development");
653 }
654
655 #[test]
656 fn a_bare_list_id_is_its_own_name() {
657 let (id, name) = split_list_id("<numpy-discussion.python.org>");
658 assert_eq!(id, "numpy-discussion.python.org");
659 assert_eq!(name, "numpy-discussion.python.org");
660
661 let (id, name) = split_list_id("mu-discuss.googlegroups.com");
662 assert_eq!(id, "mu-discuss.googlegroups.com");
663 assert_eq!(name, "mu-discuss.googlegroups.com");
664 }
665
666 #[test]
667 fn a_quoted_list_name_loses_its_quotes() {
668 let (_, name) = split_list_id("\"Culture STIC\" <culture.stic.fr>");
669 assert_eq!(name, "Culture STIC");
670 }
671
672 fn message_with(headers: &str) -> tempfile::NamedTempFile {
673 let file = tempfile::NamedTempFile::new().unwrap();
674 std::fs::write(file.path(), format!("{headers}\n\nThe body.\n")).unwrap();
675 file
676 }
677
678 #[test]
679 fn reads_the_list_id_header() {
680 let file = message_with("From: a@b.c\nList-Id: Emacs <emacs-devel.gnu.org>\nSubject: hi");
681 assert_eq!(
682 read_list_id(file.path()).as_deref(),
683 Some("Emacs <emacs-devel.gnu.org>")
684 );
685 }
686
687 #[test]
688 fn the_header_name_is_matched_regardless_of_case() {
689 let file = message_with("LIST-ID: <x.example.com>");
690 assert_eq!(
691 read_list_id(file.path()).as_deref(),
692 Some("<x.example.com>")
693 );
694 }
695
696 #[test]
697 fn a_folded_list_id_is_rejoined() {
698 let file = message_with("List-Id: A very long list name\n <long.example.com>");
699 assert_eq!(
700 read_list_id(file.path()).as_deref(),
701 Some("A very long list name <long.example.com>")
702 );
703 }
704
705 #[test]
706 fn a_message_without_the_header_yields_nothing() {
707 let file = message_with("From: a@b.c\nSubject: hi");
708 assert_eq!(read_list_id(file.path()), None);
709 }
710
711 #[test]
713 fn the_scan_stops_at_the_end_of_the_headers() {
714 let file = tempfile::NamedTempFile::new().unwrap();
715 std::fs::write(
716 file.path(),
717 "From: a@b.c\n\nList-Id: <not-a-header.example.com>\n",
718 )
719 .unwrap();
720 assert_eq!(read_list_id(file.path()), None);
721 }
722}