Skip to main content

ecr_store/notmuch/
mod.rs

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/// A mailing list found by scanning `List-Id` headers.
23#[derive(Debug, Clone, serde::Serialize)]
24pub struct MailingList {
25    /// The bare identifier, which is what `List:` searches.
26    pub id: String,
27    /// The human label the header carried, falling back to the identifier.
28    pub name: String,
29    pub count: u32,
30}
31
32/// Reads just the `List-Id` header, stopping at the blank line that ends them.
33/// Whole messages are megabytes; the headers are the first few hundred bytes.
34fn 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        // A folded header continues on a line starting with whitespace.
52        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
73/// `Emacs development <emacs-devel.gnu.org>` is a name and an id; a bare
74/// `<id>` or an unbracketed value is only an id.
75fn 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    /// Parsing a message dominates a body request, and a maildir file never
94    /// changes in place, so the parse is worth keeping.
95    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    /// Counts many queries in one process.
166    ///
167    /// The sidebar asks for a count per row, and spawning notmuch per row costs
168    /// more than the counting does. `--batch` reads one query per line and
169    /// writes one count per line, in order. No write lock: counting is a read,
170    /// and Xapian's single-writer rule does not reach it.
171    ///
172    /// An empty line means "everything" to notmuch, so a blank query would
173    /// silently return the whole database rather than nothing. They are
174    /// substituted before sending and answered as 0.
175    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        // A short read would silently shift every count onto the wrong row.
232        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        // Resolving a message to its file is a subprocess; the mapping only
312        // changes when the message moves, which the mtime check catches.
313        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    /// The address book, built from who you have written to and who has
380    /// written to you. Recipients are collected first so they outrank senders.
381    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    /// The mailing lists recent mail arrived from.
408    ///
409    /// notmuch cannot enumerate `List-Id` values — it is not even a searchable
410    /// prefix unless `index.header.List` is configured — so this reads the
411    /// header off recent message files and tallies them. It is bounded by
412    /// `scan` files for that reason: the cost is real, and unbounded it would
413    /// grow with the maildir.
414    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        // Reading a few hundred files is blocking work; keep it off the runtime.
429        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    /// Whether `List:` is a searchable prefix, which needs
457    /// `index.header.List=List-Id` and a reindex. Without it the list rows
458    /// would render and then match nothing when clicked.
459    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    /// Every tag in the database, for query completion.
467    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, &current);
570                current.clear();
571            }
572            _ => current.push(ch),
573        }
574    }
575    push_address(&mut out, &current);
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    /// The body is not headers: a line there that looks like one must not count.
712    #[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}