Skip to main content

io_email/envelope/m2dir/
search.rs

1//! m2dir envelope-search coroutine: same shape as
2//! [`crate::envelope::m2dir::list::M2dirEnvelopeList`], with shared
3//! filter + sort + paginate applied client-side.
4//!
5//! Body matching reuses the already-loaded message bytes.
6//!
7//! # Example
8//!
9//! ```rust,ignore
10//! use io_email::envelope::m2dir::search::M2dirEnvelopeSearch;
11//!
12//! let envs = client.run(M2dirEnvelopeSearch::new(&client.root, "INBOX", Some(&query), None, None, false)?)?;
13//! ```
14
15use alloc::{collections::BTreeSet, string::String, vec::Vec};
16use core::{cmp::Ordering, mem};
17use std::path::PathBuf;
18
19use chrono::{DateTime, FixedOffset, NaiveDate};
20use io_m2dir::{
21    coroutine::*,
22    entry::{
23        list::{
24            M2dirEntryList as InnerList, M2dirEntryListError as InnerErr,
25            M2dirEntryListOptions as InnerOpts,
26        },
27        types::M2dirEntry,
28    },
29    flag::types::M2dirFlags,
30    m2dir::types::M2dir,
31    path::M2dirPath,
32};
33use log::trace;
34use mail_parser::MessageParser;
35use thiserror::Error;
36
37use crate::{
38    address::Address,
39    envelope::types::Envelope,
40    m2dir::convert::{InvalidMailboxName, envelope_from, paginate, resolve_mailbox},
41    search::{
42        filter::query::SearchEmailsFilterQuery,
43        query::SearchEmailsQuery,
44        sort::query::{SearchEmailsSorter, SearchEmailsSorterKind, SearchEmailsSorterOrder},
45    },
46};
47
48/// Errors produced by [`M2dirEnvelopeSearch`].
49#[derive(Debug, Error)]
50pub enum M2dirEnvelopeSearchError {
51    #[error(transparent)]
52    List(#[from] InnerErr),
53    #[error(transparent)]
54    InvalidMailbox(#[from] InvalidMailboxName),
55    #[error("coroutine was resumed with an M2dirArg variant it did not request")]
56    UnexpectedArg,
57    #[error("coroutine was resumed after completion")]
58    ResumedAfterDone,
59    #[error("failed to parse m2dir message at {0:?}")]
60    Parse(M2dirPath),
61}
62
63/// I/O-free coroutine listing then client-side filtering + sorting +
64/// paginating an m2dir's messages.
65pub struct M2dirEnvelopeSearch {
66    state: State,
67    m2dir: M2dir,
68    filter: Option<SearchEmailsFilterQuery>,
69    sort: Option<Vec<SearchEmailsSorter>>,
70    page: Option<u32>,
71    page_size: Option<u32>,
72    with_attachment: bool,
73}
74
75impl M2dirEnvelopeSearch {
76    pub fn new(
77        root: impl Into<PathBuf>,
78        mailbox: &str,
79        query: Option<&SearchEmailsQuery>,
80        page: Option<u32>,
81        page_size: Option<u32>,
82        with_attachment: bool,
83    ) -> Result<Self, M2dirEnvelopeSearchError> {
84        trace!("prepare m2dir envelope search");
85        let m2dir = resolve_mailbox(root, mailbox)?;
86        let inner = InnerList::new(m2dir.clone(), InnerOpts::default());
87        Ok(Self {
88            state: State::Listing(inner),
89            m2dir,
90            filter: query.and_then(|q| q.filter.clone()),
91            sort: query.and_then(|q| q.sort.clone()),
92            page,
93            page_size,
94            with_attachment,
95        })
96    }
97}
98
99enum State {
100    Listing(InnerList),
101    Reading(Vec<M2dirEntry>),
102    Done,
103}
104
105/// Reads a .meta/<id>.flags file (one flag per non-empty trimmed line).
106fn parse_meta_flags(bytes: &[u8]) -> M2dirFlags {
107    let Ok(text) = core::str::from_utf8(bytes) else {
108        return M2dirFlags::default();
109    };
110    text.lines()
111        .map(str::trim)
112        .filter(|line| !line.is_empty())
113        .collect()
114}
115
116/// Evaluates `filter` against `envelope`; `body` scans `raw`.
117fn matches_filter(envelope: &Envelope, raw: &[u8], filter: &SearchEmailsFilterQuery) -> bool {
118    use SearchEmailsFilterQuery as Q;
119
120    match filter {
121        Q::And(left, right) => {
122            matches_filter(envelope, raw, left) && matches_filter(envelope, raw, right)
123        }
124        Q::Or(left, right) => {
125            matches_filter(envelope, raw, left) || matches_filter(envelope, raw, right)
126        }
127        Q::Not(inner) => !matches_filter(envelope, raw, inner),
128        Q::Date(target) => same_day(envelope.date, *target),
129        Q::AfterDate(target) => after_day(envelope.date, *target),
130        Q::From(pattern) => addresses_contain(&envelope.from, pattern),
131        Q::To(pattern) => addresses_contain(&envelope.to, pattern),
132        Q::Subject(pattern) => contains_ci(&envelope.subject, pattern),
133        Q::Body(pattern) => body_contains(raw, pattern),
134        Q::Flag(flag) => envelope.flags.contains(flag),
135    }
136}
137
138fn body_contains(raw: &[u8], pattern: &str) -> bool {
139    let Some(msg) = MessageParser::new().parse(raw) else {
140        return false;
141    };
142    let needle = pattern.as_bytes();
143    for part in msg.text_bodies() {
144        if contains_ignore_ascii_case(part.contents(), needle) {
145            return true;
146        }
147    }
148    for part in msg.html_bodies() {
149        if contains_ignore_ascii_case(part.contents(), needle) {
150            return true;
151        }
152    }
153    false
154}
155
156fn contains_ignore_ascii_case(haystack: &[u8], needle: &[u8]) -> bool {
157    if needle.is_empty() {
158        return true;
159    }
160    if needle.len() > haystack.len() {
161        return false;
162    }
163    haystack
164        .windows(needle.len())
165        .any(|w| w.eq_ignore_ascii_case(needle))
166}
167
168fn same_day(date: Option<DateTime<FixedOffset>>, target: NaiveDate) -> bool {
169    date.map(|d| d.date_naive() == target).unwrap_or(false)
170}
171
172fn after_day(date: Option<DateTime<FixedOffset>>, target: NaiveDate) -> bool {
173    date.map(|d| d.date_naive() > target).unwrap_or(false)
174}
175
176fn addresses_contain(addrs: &[Address], pattern: &str) -> bool {
177    let needle = pattern.to_lowercase();
178    addrs.iter().any(|addr| {
179        let email_hit = addr.email.to_lowercase().contains(&needle);
180        let name_hit = addr
181            .name
182            .as_deref()
183            .map(|n| n.to_lowercase().contains(&needle))
184            .unwrap_or(false);
185        email_hit || name_hit
186    })
187}
188
189fn contains_ci(haystack: &str, needle: &str) -> bool {
190    haystack.to_lowercase().contains(&needle.to_lowercase())
191}
192
193fn compare_with(left: &Envelope, right: &Envelope, sort: &[SearchEmailsSorter]) -> Ordering {
194    for SearchEmailsSorter(kind, order) in sort {
195        let cmp = match kind {
196            SearchEmailsSorterKind::Date => left.date.cmp(&right.date),
197            SearchEmailsSorterKind::From => {
198                first_addr_key(&left.from).cmp(&first_addr_key(&right.from))
199            }
200            SearchEmailsSorterKind::To => first_addr_key(&left.to).cmp(&first_addr_key(&right.to)),
201            SearchEmailsSorterKind::Subject => left.subject.cmp(&right.subject),
202        };
203        let cmp = match order {
204            SearchEmailsSorterOrder::Ascending => cmp,
205            SearchEmailsSorterOrder::Descending => cmp.reverse(),
206        };
207        if cmp != Ordering::Equal {
208            return cmp;
209        }
210    }
211    Ordering::Equal
212}
213
214fn first_addr_key(addrs: &[Address]) -> Option<String> {
215    addrs.first().map(|a| {
216        a.name
217            .as_deref()
218            .map(str::to_lowercase)
219            .unwrap_or_else(|| a.email.to_lowercase())
220    })
221}
222
223impl M2dirCoroutine for M2dirEnvelopeSearch {
224    type Yield = M2dirYield;
225    type Return = Result<Vec<Envelope>, M2dirEnvelopeSearchError>;
226
227    fn resume(&mut self, arg: Option<M2dirArg>) -> M2dirCoroutineState<Self::Yield, Self::Return> {
228        match mem::replace(&mut self.state, State::Done) {
229            State::Listing(mut inner) => match inner.resume(arg) {
230                M2dirCoroutineState::Yielded(y) => {
231                    self.state = State::Listing(inner);
232                    M2dirCoroutineState::Yielded(y)
233                }
234                M2dirCoroutineState::Complete(Ok(entries)) => {
235                    if entries.is_empty() {
236                        return M2dirCoroutineState::Complete(Ok(Vec::new()));
237                    }
238                    let mut paths: BTreeSet<M2dirPath> = BTreeSet::new();
239                    for entry in &entries {
240                        paths.insert(entry.path().clone());
241                        paths.insert(self.m2dir.flags_path(entry.id()));
242                    }
243                    self.state = State::Reading(entries);
244                    M2dirCoroutineState::Yielded(M2dirYield::WantsFileRead(paths))
245                }
246                M2dirCoroutineState::Complete(Err(err)) => {
247                    M2dirCoroutineState::Complete(Err(err.into()))
248                }
249            },
250            State::Reading(entries) => {
251                let Some(M2dirArg::FileRead(mut contents)) = arg else {
252                    self.state = State::Reading(entries);
253                    return M2dirCoroutineState::Complete(Err(
254                        M2dirEnvelopeSearchError::UnexpectedArg,
255                    ));
256                };
257                let parser = MessageParser::default();
258                let mut hits: Vec<Envelope> = Vec::with_capacity(entries.len());
259
260                for entry in entries {
261                    let Some(body) = contents.remove(entry.path()) else {
262                        continue;
263                    };
264                    let flags_bytes = contents
265                        .remove(&self.m2dir.flags_path(entry.id()))
266                        .unwrap_or_default();
267                    let flags = parse_meta_flags(&flags_bytes);
268
269                    let parsed = if self.with_attachment {
270                        parser.parse(&body)
271                    } else {
272                        parser.parse_headers(&body)
273                    };
274                    let Some(parsed) = parsed else {
275                        return M2dirCoroutineState::Complete(Err(
276                            M2dirEnvelopeSearchError::Parse(entry.path().clone()),
277                        ));
278                    };
279
280                    let mut envelope = envelope_from(&entry, &flags, &parsed);
281                    if self.with_attachment {
282                        envelope.has_attachment = Some(parsed.attachment_count() > 0);
283                    }
284
285                    let keep = match self.filter.as_ref() {
286                        Some(f) => matches_filter(&envelope, &body, f),
287                        None => true,
288                    };
289                    if keep {
290                        hits.push(envelope);
291                    }
292                }
293
294                match self.sort.as_deref() {
295                    Some(sort) if !sort.is_empty() => {
296                        hits.sort_by(|a, b| compare_with(a, b, sort));
297                    }
298                    _ => hits.sort_by(|a, b| b.date.cmp(&a.date)),
299                }
300
301                M2dirCoroutineState::Complete(Ok(paginate(hits, self.page, self.page_size)))
302            }
303            State::Done => {
304                M2dirCoroutineState::Complete(Err(M2dirEnvelopeSearchError::ResumedAfterDone))
305            }
306        }
307    }
308}