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 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
54pub 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
70fn 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
84pub 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#[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 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}