Skip to main content

ecr_store/notmuch/
json.rs

1use super::parse_address_list;
2use ecr_core::message::{Message, MessageId, ThreadId, ThreadSummary};
3use serde::Deserialize;
4use std::collections::BTreeMap;
5use std::collections::BTreeSet;
6use std::path::PathBuf;
7
8#[derive(Debug, Deserialize)]
9pub struct SearchItem {
10    pub thread: String,
11    #[serde(default)]
12    pub timestamp: i64,
13    #[serde(default)]
14    pub date_relative: String,
15    #[serde(default)]
16    pub authors: String,
17    #[serde(default)]
18    pub subject: String,
19    #[serde(default)]
20    pub query: Vec<Option<String>>,
21    #[serde(default)]
22    pub tags: Vec<String>,
23    #[serde(default)]
24    pub matched: usize,
25    #[serde(default)]
26    pub total: usize,
27}
28
29impl SearchItem {
30    pub fn into_summary(self) -> ThreadSummary {
31        // Slot 0 is the matched messages and slot 1 the unmatched ones. Taking
32        // the first non-null of either could name a message the query excluded.
33        let newest_message = self
34            .query
35            .into_iter()
36            .next()
37            .flatten()
38            .and_then(|q| newest_of(&q));
39
40        ThreadSummary {
41            id: ThreadId(self.thread),
42            authors: split_authors(&self.authors),
43            subject: self.subject,
44            timestamp: self.timestamp,
45            date_relative: self.date_relative,
46            matched: self.matched,
47            total: self.total,
48            tags: self.tags.into_iter().collect(),
49            newest_message,
50        }
51    }
52}
53
54/// Every message the query matched in this thread. Slot 1 holds the ones it did
55/// not and is deliberately left alone; see `newest_of`.
56pub fn matched_ids(item: &SearchItem) -> Vec<String> {
57    item.query
58        .first()
59        .and_then(|slot| slot.as_deref())
60        .map(|q| {
61            q.split_whitespace()
62                .filter_map(|token| token.strip_prefix("id:"))
63                .map(|id| id.trim_matches('"').to_string())
64                .filter(|id| !id.is_empty())
65                .collect()
66        })
67        .unwrap_or_default()
68}
69
70/// notmuch's `query[0]` is a query naming every matched message, not one id:
71/// `id:msg3@example.com id:msg4@example.com`. Stripping only the leading `id:`
72/// left the rest embedded in the value, which became a query matching nothing —
73/// and `notmuch tag --batch` ignores such a line without failing, so tagging any
74/// thread with more than one message silently did nothing at all. The ids are in
75/// date order, so the newest is the last.
76fn newest_of(query: &str) -> Option<MessageId> {
77    query
78        .split_whitespace()
79        .filter_map(|token| token.strip_prefix("id:"))
80        .rfind(|id| !id.is_empty())
81        .map(|id| MessageId(id.trim_matches('"').to_string()))
82}
83
84/// notmuch joins the authors of a thread into one string — matched first, then
85/// `|`, then the rest, each list separated by `, ` — and a display name
86/// containing a comma is therefore indistinguishable from two authors.
87/// `Anthropic, PBC` comes back as two. Nothing can recover the difference, so
88/// the mail index renders the same string and splits it here rather than
89/// keeping its own per-message list: one wrong answer everywhere beats two
90/// answers that disagree depending on which path served the request.
91pub fn split_authors(raw: &str) -> Vec<String> {
92    raw.split(['|', ','])
93        .map(str::trim)
94        .filter(|a| !a.is_empty())
95        .map(str::to_string)
96        .collect()
97}
98
99#[derive(Debug, Deserialize)]
100#[serde(transparent)]
101pub struct ShowOutput(pub Vec<Vec<ThreadNode>>);
102
103impl ShowOutput {
104    pub fn flatten(self) -> Vec<ShowMessage> {
105        let mut out = Vec::new();
106        for thread in self.0 {
107            for node in thread {
108                node.flatten_into(&mut out);
109            }
110        }
111        out
112    }
113}
114
115/// The message is `null` whenever `notmuch show --entire-thread=false` walks
116/// *through* a message the query did not match to reach a reply that it did.
117/// Its replies are still there, so a node has to be skipped rather than
118/// stopping the walk — and typing it as a `ShowMessage` fails the whole parse
119/// with `invalid type: null`, which surfaces as notmuch having malfunctioned.
120#[derive(Debug, Deserialize)]
121pub struct ThreadNode(
122    pub Option<ShowMessage>,
123    #[serde(default)] pub Vec<ThreadNode>,
124);
125
126impl ThreadNode {
127    fn flatten_into(self, out: &mut Vec<ShowMessage>) {
128        if let Some(message) = self.0 {
129            out.push(message);
130        }
131        for reply in self.1 {
132            reply.flatten_into(out);
133        }
134    }
135}
136
137#[derive(Debug, Default, Deserialize)]
138#[serde(default)]
139pub struct ShowMessage {
140    pub id: String,
141    pub thread: String,
142    pub timestamp: i64,
143    pub date_relative: String,
144    pub tags: Vec<String>,
145    pub filename: Vec<PathBuf>,
146    pub excluded: bool,
147    pub headers: BTreeMap<String, serde_json::Value>,
148}
149
150impl ShowMessage {
151    fn header(&self, name: &str) -> Option<String> {
152        let value = self
153            .headers
154            .iter()
155            .find(|(k, _)| k.eq_ignore_ascii_case(name))
156            .map(|(_, v)| v)?;
157
158        match value {
159            serde_json::Value::String(s) => Some(s.clone()),
160            serde_json::Value::Array(items) => {
161                let joined = items
162                    .iter()
163                    .filter_map(|i| i.as_str())
164                    .collect::<Vec<_>>()
165                    .join(", ");
166                (!joined.is_empty()).then_some(joined)
167            }
168            _ => None,
169        }
170    }
171
172    fn addresses(&self, name: &str) -> Vec<super::Address> {
173        self.header(name)
174            .map(|raw| parse_address_list(&raw))
175            .unwrap_or_default()
176    }
177
178    pub fn primary_file(&self) -> Option<&PathBuf> {
179        self.filename
180            .iter()
181            .find(|p| p.is_file())
182            .or_else(|| self.filename.first())
183    }
184
185    pub fn into_message(self) -> Option<Message> {
186        if self.id.is_empty() {
187            return None;
188        }
189
190        let subject = self.header("Subject").unwrap_or_default();
191        let date = self.header("Date").unwrap_or_default();
192        let from = self.addresses("From");
193        let to = self.addresses("To");
194        let cc = self.addresses("Cc");
195        let bcc = self.addresses("Bcc");
196        let reply_to = self.addresses("Reply-To");
197        let in_reply_to = self.header("In-Reply-To");
198        let references = self
199            .header("References")
200            .map(|r| {
201                r.split_whitespace()
202                    .map(|s| s.trim_matches(['<', '>']).to_string())
203                    .filter(|s| !s.is_empty())
204                    .collect()
205            })
206            .unwrap_or_default();
207
208        let tags: BTreeSet<String> = self.tags.into_iter().collect();
209
210        Some(Message {
211            id: MessageId(self.id),
212            thread_id: ThreadId(self.thread),
213            subject,
214            from,
215            to,
216            cc,
217            bcc,
218            reply_to,
219            date,
220            timestamp: self.timestamp,
221            tags,
222            in_reply_to,
223            references,
224            parts: Vec::new(),
225            excluded: self.excluded,
226        })
227    }
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    const LIVE_SEARCH: &str = r#"[{
235      "thread": "000000000000633e",
236      "timestamp": 20250324210,
237      "date_relative": "the future",
238      "matched": 1,
239      "total": 1,
240      "authors": "Google",
241      "subject": "CRED's access to your Google Account data will expire soon",
242      "query": ["id:Igz4LEN4rI70Ta4IkKoKpg@notifications.google.com", null],
243      "tags": ["inbox", "main"]
244    }]"#;
245
246    #[test]
247    fn parses_the_live_search_output() {
248        let items: Vec<SearchItem> = serde_json::from_str(LIVE_SEARCH).unwrap();
249        let summary = items.into_iter().next().unwrap().into_summary();
250
251        assert_eq!(summary.id.as_str(), "000000000000633e");
252        assert_eq!(summary.authors, vec!["Google"]);
253        assert!(summary.tags.contains("inbox"));
254        assert_eq!(
255            summary.newest_message.as_ref().map(|m| m.as_str()),
256            Some("Igz4LEN4rI70Ta4IkKoKpg@notifications.google.com")
257        );
258    }
259
260    #[test]
261    fn the_id_prefix_is_stripped_from_the_query_field() {
262        let items: Vec<SearchItem> = serde_json::from_str(LIVE_SEARCH).unwrap();
263        let summary = items.into_iter().next().unwrap().into_summary();
264        assert!(!summary.newest_message.unwrap().as_str().starts_with("id:"));
265    }
266
267    #[test]
268    fn a_thread_of_several_messages_yields_the_newest_id_alone() {
269        // notmuch names every matched message in one query string. Taking the
270        // whole string as an id produced a batch line that matched nothing.
271        let raw = r#"[{
272          "thread": "0000000000000003",
273          "timestamp": 1775048400,
274          "date_relative": "April 01",
275          "matched": 2,
276          "total": 2,
277          "authors": "bob@example.com, charlie@example.com",
278          "subject": "Completely different topic",
279          "query": ["id:msg3@example.com id:msg4@example.com", null],
280          "tags": ["inbox"]
281        }]"#;
282
283        let items: Vec<SearchItem> = serde_json::from_str(raw).unwrap();
284        let summary = items.into_iter().next().unwrap().into_summary();
285
286        assert_eq!(
287            summary.newest_message.as_ref().map(|m| m.as_str()),
288            Some("msg4@example.com"),
289        );
290    }
291
292    #[test]
293    fn an_id_is_never_left_holding_a_space() {
294        let raw = r#"[{
295          "thread": "t",
296          "timestamp": 1,
297          "date_relative": "now",
298          "matched": 3,
299          "total": 3,
300          "authors": "a",
301          "subject": "s",
302          "query": ["id:a@x id:b@x id:c@x", null],
303          "tags": []
304        }]"#;
305
306        let items: Vec<SearchItem> = serde_json::from_str(raw).unwrap();
307        let id = items
308            .into_iter()
309            .next()
310            .unwrap()
311            .into_summary()
312            .newest_message;
313
314        let id = id.unwrap();
315        assert!(!id.as_str().contains(' '), "{}", id.as_str());
316        assert_eq!(id.as_str(), "c@x");
317    }
318
319    #[test]
320    fn a_thread_with_no_matched_messages_has_no_id() {
321        let raw = r#"[{
322          "thread": "t",
323          "timestamp": 1,
324          "date_relative": "now",
325          "matched": 0,
326          "total": 1,
327          "authors": "a",
328          "subject": "s",
329          "query": [null, "id:a@x"],
330          "tags": []
331        }]"#;
332
333        let items: Vec<SearchItem> = serde_json::from_str(raw).unwrap();
334        assert!(items
335            .into_iter()
336            .next()
337            .unwrap()
338            .into_summary()
339            .newest_message
340            .is_none());
341    }
342
343    #[test]
344    fn splits_the_authors_string_notmuch_produces() {
345        assert_eq!(
346            split_authors("Alice, Bob| Charlie"),
347            vec!["Alice", "Bob", "Charlie"]
348        );
349        assert!(split_authors("").is_empty());
350    }
351
352    const LIVE_SHOW: &str = r#"[[[{
353      "id": "Igz4LEN4rI70Ta4IkKoKpg@notifications.google.com",
354      "match": true,
355      "excluded": false,
356      "thread": "000000000000633e",
357      "filename": ["/nonexistent/a", "/nonexistent/b"],
358      "timestamp": 20250324210,
359      "date_relative": "the future",
360      "tags": ["inbox", "main"],
361      "headers": {
362        "Subject": "CRED's access will expire",
363        "From": "Google <no-reply@accounts.google.com>",
364        "To": "alice@example.com",
365        "Date": "Mon, 16 Sep 2611 18:03:30 +0000"
366      }
367    }, []]]]"#;
368
369    #[test]
370    fn parses_the_live_show_output() {
371        let output: ShowOutput = serde_json::from_str(LIVE_SHOW).unwrap();
372        let messages = output.flatten();
373        assert_eq!(messages.len(), 1);
374
375        let message = messages.into_iter().next().unwrap().into_message().unwrap();
376        assert_eq!(message.subject, "CRED's access will expire");
377        assert_eq!(message.from[0].email, "no-reply@accounts.google.com");
378        assert_eq!(message.to[0].email, "alice@example.com");
379        assert!(message.tags.contains("main"));
380    }
381
382    #[test]
383    fn nested_replies_are_flattened_in_order() {
384        let json = r#"[[[{"id":"a","headers":{}}, [[{"id":"b","headers":{}}, []]]]]]"#;
385        let output: ShowOutput = serde_json::from_str(json).unwrap();
386        let ids: Vec<_> = output.flatten().into_iter().map(|m| m.id).collect();
387        assert_eq!(ids, vec!["a", "b"]);
388    }
389
390    #[test]
391    fn a_ghost_message_without_an_id_is_dropped() {
392        let json = r#"[[[{"headers":{}}, []]]]"#;
393        let output: ShowOutput = serde_json::from_str(json).unwrap();
394        assert!(output
395            .flatten()
396            .into_iter()
397            .all(|m| m.into_message().is_none()));
398    }
399
400    #[test]
401    fn headers_are_matched_case_insensitively() {
402        let json = r#"[[[{"id":"a","headers":{"subject":"lower","REPLY-TO":"x@y.z"}}, []]]]"#;
403        let output: ShowOutput = serde_json::from_str(json).unwrap();
404        let message = output
405            .flatten()
406            .into_iter()
407            .next()
408            .unwrap()
409            .into_message()
410            .unwrap();
411
412        assert_eq!(message.subject, "lower");
413        assert_eq!(message.reply_to[0].email, "x@y.z");
414    }
415
416    #[test]
417    fn references_are_split_and_unbracketed() {
418        let json = r#"[[[{"id":"a","headers":{"References":"<one@x> <two@y>"}}, []]]]"#;
419        let output: ShowOutput = serde_json::from_str(json).unwrap();
420        let message = output
421            .flatten()
422            .into_iter()
423            .next()
424            .unwrap()
425            .into_message()
426            .unwrap();
427
428        assert_eq!(message.references, vec!["one@x", "two@y"]);
429    }
430}