Skip to main content

io_email/envelope/jmap/
search.rs

1//! JMAP envelope-search coroutine: batched Email/query + Email/get
2//! scoped to one mailbox id.
3//!
4//! AND/OR/NOT become [`JmapFilterOperator`](io_jmap::rfc8620::JmapFilterOperator)s;
5//! leaves become flat [`JmapEmailFilter`] conditions. Date filters
6//! target the Date: header (sentAt) while JMAP before/after are
7//! receivedAt-anchored, so the coroutine over-approximates on the
8//! wire and re-applies the strict predicate client-side via
9//! [`PostFilter`], paginating after the trim.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use io_email::envelope::jmap::search::JmapEnvelopeSearch;
15//!
16//! let envs = client.run(JmapEnvelopeSearch::new(&session, &auth, "mailbox-id", Some(&query), None, None)?)?;
17//! ```
18
19use alloc::{string::String, vec, vec::Vec};
20use core::mem;
21
22use chrono::{Datelike, NaiveDate};
23use io_jmap::{
24    coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
25    rfc8620::{JmapFilter, JmapSession},
26    rfc8621::email::{
27        JmapEmailComparator, JmapEmailFilter, JmapEmailSortProperty,
28        query::{
29            JmapEmailQuery as InnerQuery, JmapEmailQueryError as QueryErr, JmapEmailQueryOptions,
30        },
31    },
32};
33use log::trace;
34use secrecy::SecretString;
35use thiserror::Error;
36
37use crate::{
38    envelope::types::Envelope,
39    jmap::convert::{compute_position_limit, envelope_from, envelope_properties, keyword_from},
40    search::{
41        filter::query::SearchEmailsFilterQuery,
42        query::SearchEmailsQuery,
43        sort::query::{SearchEmailsSorter, SearchEmailsSorterKind, SearchEmailsSorterOrder},
44    },
45};
46
47/// Errors produced by [`JmapEnvelopeSearch`].
48#[derive(Debug, Error)]
49pub enum JmapEnvelopeSearchError {
50    #[error(transparent)]
51    EmailQuery(#[from] QueryErr),
52    #[error("coroutine was resumed after completion")]
53    ResumedAfterDone,
54}
55
56/// Residual client-side predicate left after the JMAP filter ran.
57#[derive(Clone, Debug)]
58pub enum PostFilter {
59    Date(NaiveDate),
60    AfterDate(NaiveDate),
61}
62
63/// I/O-free coroutine running Email/query + Email/get scoped to one
64/// JMAP mailbox id, with optional client-side `sentAt` re-check.
65pub struct JmapEnvelopeSearch {
66    state: State,
67    page: Option<u32>,
68    page_size: Option<u32>,
69}
70
71impl JmapEnvelopeSearch {
72    pub fn new(
73        session: &JmapSession,
74        http_auth: &SecretString,
75        mailbox: &str,
76        query: Option<&SearchEmailsQuery>,
77        page: Option<u32>,
78        page_size: Option<u32>,
79    ) -> Result<Self, JmapEnvelopeSearchError> {
80        trace!("prepare JMAP envelope search");
81        let Converted {
82            filter,
83            sort,
84            post_filters,
85        } = build(query, mailbox.into());
86
87        let paginate_client_side = !post_filters.is_empty();
88        let (position, limit) = if paginate_client_side {
89            (None, None)
90        } else {
91            compute_position_limit(page, page_size)
92        };
93
94        let opts = JmapEmailQueryOptions {
95            filter: Some(filter),
96            sort: Some(sort),
97            position,
98            limit,
99            properties: Some(envelope_properties()),
100        };
101        let inner = InnerQuery::new(session, http_auth, opts)?;
102        Ok(Self {
103            state: State::Searching {
104                inner,
105                post_filters,
106            },
107            page,
108            page_size,
109        })
110    }
111}
112
113enum State {
114    Searching {
115        inner: InnerQuery,
116        post_filters: Vec<PostFilter>,
117    },
118    Done,
119}
120
121/// JMAP-side filter + sort, plus the residual client-side predicates.
122struct Converted {
123    filter: JmapFilter<JmapEmailFilter>,
124    sort: Vec<JmapEmailComparator>,
125    post_filters: Vec<PostFilter>,
126}
127
128/// Converts `query` into JMAP primitives, AND-scoped to `mailbox_id`.
129fn build(query: Option<&SearchEmailsQuery>, mailbox_id: String) -> Converted {
130    let mailbox_scope = JmapFilter::Condition(JmapEmailFilter {
131        in_mailbox: Some(mailbox_id),
132        ..JmapEmailFilter::default()
133    });
134
135    let mut post_filters = Vec::new();
136    let user_filter = query
137        .and_then(|q| q.filter.as_ref())
138        .map(|f| convert_filter(f, &mut post_filters));
139
140    let filter = match user_filter {
141        Some(uf) => JmapFilter::and(vec![mailbox_scope, uf]),
142        None => mailbox_scope,
143    };
144
145    let sort = query
146        .and_then(|q| q.sort.as_deref())
147        .filter(|chain| !chain.is_empty())
148        .map(|chain| chain.iter().map(convert_sorter).collect())
149        .unwrap_or_else(|| vec![sent_at_desc()]);
150
151    Converted {
152        filter,
153        sort,
154        post_filters,
155    }
156}
157
158/// Recursively translates `node` into a JMAP filter tree; date leaves
159/// push a [`PostFilter`] so the strict sentAt rule can be re-applied.
160fn convert_filter(
161    node: &SearchEmailsFilterQuery,
162    post_filters: &mut Vec<PostFilter>,
163) -> JmapFilter<JmapEmailFilter> {
164    use SearchEmailsFilterQuery as Q;
165
166    match node {
167        Q::And(left, right) => JmapFilter::and(vec![
168            convert_filter(left, post_filters),
169            convert_filter(right, post_filters),
170        ]),
171        Q::Or(left, right) => JmapFilter::or(vec![
172            convert_filter(left, post_filters),
173            convert_filter(right, post_filters),
174        ]),
175        Q::Not(inner) => JmapFilter::not(vec![convert_filter(inner, post_filters)]),
176
177        Q::From(pattern) => JmapFilter::Condition(JmapEmailFilter {
178            from: Some(pattern.clone()),
179            ..JmapEmailFilter::default()
180        }),
181        Q::To(pattern) => JmapFilter::Condition(JmapEmailFilter {
182            to: Some(pattern.clone()),
183            ..JmapEmailFilter::default()
184        }),
185        Q::Subject(pattern) => JmapFilter::Condition(JmapEmailFilter {
186            subject: Some(pattern.clone()),
187            ..JmapEmailFilter::default()
188        }),
189        Q::Body(pattern) => JmapFilter::Condition(JmapEmailFilter {
190            body: Some(pattern.clone()),
191            ..JmapEmailFilter::default()
192        }),
193        Q::Flag(flag) => JmapFilter::Condition(JmapEmailFilter {
194            has_keyword: Some(keyword_from(flag)),
195            ..JmapEmailFilter::default()
196        }),
197
198        // NOTE: over-approximate via after = start-of-day(D); the
199        // exact sent-at rule is re-checked client-side.
200        Q::Date(target) => {
201            post_filters.push(PostFilter::Date(*target));
202            JmapFilter::Condition(JmapEmailFilter {
203                after: Some(utc_midnight(*target)),
204                ..JmapEmailFilter::default()
205            })
206        }
207        // NOTE: over-approximate via after = start-of-day(D+1); the
208        // strict sent-at rule is re-checked client-side.
209        Q::AfterDate(target) => {
210            post_filters.push(PostFilter::AfterDate(*target));
211            let bumped = target.succ_opt().unwrap_or(*target);
212            JmapFilter::Condition(JmapEmailFilter {
213                after: Some(utc_midnight(bumped)),
214                ..JmapEmailFilter::default()
215            })
216        }
217    }
218}
219
220/// True when `envelope` matches every residual predicate.
221fn post_match(envelope: &Envelope, post_filters: &[PostFilter]) -> bool {
222    post_filters.iter().all(|pf| match pf {
223        PostFilter::Date(target) => envelope
224            .date
225            .map(|d| d.date_naive() == *target)
226            .unwrap_or(false),
227        PostFilter::AfterDate(target) => envelope
228            .date
229            .map(|d| d.date_naive() > *target)
230            .unwrap_or(false),
231    })
232}
233
234fn sent_at_desc() -> JmapEmailComparator {
235    JmapEmailComparator {
236        property: JmapEmailSortProperty::SentAt,
237        is_ascending: Some(false),
238        collation: None,
239        keyword: None,
240    }
241}
242
243fn convert_sorter(sorter: &SearchEmailsSorter) -> JmapEmailComparator {
244    let SearchEmailsSorter(kind, order) = sorter;
245
246    let property = match kind {
247        SearchEmailsSorterKind::Date => JmapEmailSortProperty::SentAt,
248        SearchEmailsSorterKind::From => JmapEmailSortProperty::From,
249        SearchEmailsSorterKind::To => JmapEmailSortProperty::To,
250        SearchEmailsSorterKind::Subject => JmapEmailSortProperty::Subject,
251    };
252
253    let is_ascending = match order {
254        SearchEmailsSorterOrder::Ascending => Some(true),
255        SearchEmailsSorterOrder::Descending => Some(false),
256    };
257
258    JmapEmailComparator {
259        property,
260        is_ascending,
261        collation: None,
262        keyword: None,
263    }
264}
265
266fn paginate(envelopes: Vec<Envelope>, page: Option<u32>, page_size: Option<u32>) -> Vec<Envelope> {
267    let total = envelopes.len();
268    let size = page_size.map(|n| n as usize);
269    let start = ((page.unwrap_or(1).max(1) - 1) as usize).saturating_mul(size.unwrap_or(0));
270
271    if start >= total {
272        return Vec::new();
273    }
274
275    let end = match size {
276        Some(n) => start.saturating_add(n).min(total),
277        None => total,
278    };
279
280    envelopes[start..end].to_vec()
281}
282
283fn utc_midnight(date: NaiveDate) -> String {
284    alloc::format!(
285        "{:04}-{:02}-{:02}T00:00:00Z",
286        date.year(),
287        date.month(),
288        date.day()
289    )
290}
291
292impl JmapCoroutine for JmapEnvelopeSearch {
293    type Yield = JmapYield;
294    type Return = Result<Vec<Envelope>, JmapEnvelopeSearchError>;
295
296    fn resume(&mut self, bytes: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
297        match mem::replace(&mut self.state, State::Done) {
298            State::Searching {
299                mut inner,
300                post_filters,
301            } => match inner.resume(bytes) {
302                JmapCoroutineState::Complete(Ok(ok)) => {
303                    let mut envelopes: Vec<Envelope> =
304                        ok.emails.into_iter().map(envelope_from).collect();
305
306                    if !post_filters.is_empty() {
307                        envelopes.retain(|env| post_match(env, &post_filters));
308                        envelopes = paginate(envelopes, self.page, self.page_size);
309                    }
310
311                    JmapCoroutineState::Complete(Ok(envelopes))
312                }
313                JmapCoroutineState::Yielded(y) => {
314                    self.state = State::Searching {
315                        inner,
316                        post_filters,
317                    };
318                    JmapCoroutineState::Yielded(y)
319                }
320                JmapCoroutineState::Complete(Err(err)) => {
321                    JmapCoroutineState::Complete(Err(err.into()))
322                }
323            },
324            State::Done => {
325                JmapCoroutineState::Complete(Err(JmapEnvelopeSearchError::ResumedAfterDone))
326            }
327        }
328    }
329}
330
331#[cfg(test)]
332mod tests {
333    use alloc::boxed::Box;
334
335    use chrono::{DateTime, NaiveDate};
336    use io_jmap::rfc8620::{JmapFilterOperator, JmapFilterOperatorKind};
337
338    use super::*;
339    use crate::{address::Address, flag::types::Flag};
340
341    fn naive(y: i32, m: u32, d: u32) -> NaiveDate {
342        NaiveDate::from_ymd_opt(y, m, d).unwrap()
343    }
344
345    fn envelope_at(date: &str) -> Envelope {
346        Envelope {
347            id: "1".into(),
348            message_id: None,
349            flags: Default::default(),
350            subject: String::new(),
351            from: vec![Address {
352                name: None,
353                email: String::new(),
354            }],
355            to: vec![],
356            date: DateTime::parse_from_rfc3339(date).ok(),
357            size: 0,
358            has_attachment: None,
359        }
360    }
361
362    fn pluck_user_filter(filter: JmapFilter<JmapEmailFilter>) -> JmapFilter<JmapEmailFilter> {
363        // NOTE: build() wraps the user filter in AND(mailbox_scope,
364        // user_filter) when a user filter is present.
365        let JmapFilter::Operator(JmapFilterOperator { conditions, .. }) = filter else {
366            panic!("expected top-level AND combinator");
367        };
368        conditions.into_iter().nth(1).expect("expected user filter")
369    }
370
371    #[test]
372    fn empty_query_yields_just_the_mailbox_scope() {
373        let c = build(None, "mbox-1".into());
374        match c.filter {
375            JmapFilter::Condition(JmapEmailFilter {
376                in_mailbox: Some(id),
377                ..
378            }) => assert_eq!(id, "mbox-1"),
379            other => panic!("expected mailbox-scope condition, got {other:?}"),
380        }
381        assert!(c.post_filters.is_empty());
382        assert_eq!(c.sort.len(), 1);
383        assert!(matches!(c.sort[0].property, JmapEmailSortProperty::SentAt));
384        assert_eq!(c.sort[0].is_ascending, Some(false));
385    }
386
387    #[test]
388    fn or_translates_to_filter_operator() {
389        let q = SearchEmailsQuery {
390            filter: Some(SearchEmailsFilterQuery::Or(
391                Box::new(SearchEmailsFilterQuery::From("alice".into())),
392                Box::new(SearchEmailsFilterQuery::From("bob".into())),
393            )),
394            sort: None,
395        };
396        let c = build(Some(&q), "mbox".into());
397        let inner = pluck_user_filter(c.filter);
398        let JmapFilter::Operator(JmapFilterOperator {
399            operator,
400            conditions,
401        }) = inner
402        else {
403            panic!("expected OR operator");
404        };
405        assert_eq!(operator, JmapFilterOperatorKind::Or);
406        assert_eq!(conditions.len(), 2);
407    }
408
409    #[test]
410    fn not_wraps_a_single_subfilter() {
411        let q = SearchEmailsQuery {
412            filter: Some(SearchEmailsFilterQuery::Not(Box::new(
413                SearchEmailsFilterQuery::From("a".into()),
414            ))),
415            sort: None,
416        };
417        let c = build(Some(&q), "mbox".into());
418        let inner = pluck_user_filter(c.filter);
419        let JmapFilter::Operator(JmapFilterOperator {
420            operator,
421            conditions,
422        }) = inner
423        else {
424            panic!("expected NOT operator");
425        };
426        assert_eq!(operator, JmapFilterOperatorKind::Not);
427        assert_eq!(conditions.len(), 1);
428    }
429
430    #[test]
431    fn date_clause_records_post_filter_and_bounds_after() {
432        let q = SearchEmailsQuery {
433            filter: Some(SearchEmailsFilterQuery::Date(naive(2026, 1, 15))),
434            sort: None,
435        };
436        let c = build(Some(&q), "mbox".into());
437        assert!(matches!(
438            c.post_filters.as_slice(),
439            [PostFilter::Date(d)] if *d == naive(2026, 1, 15)
440        ));
441        let inner = pluck_user_filter(c.filter);
442        match inner {
443            JmapFilter::Condition(JmapEmailFilter { after, .. }) => {
444                assert_eq!(after.as_deref(), Some("2026-01-15T00:00:00Z"));
445            }
446            other => panic!("expected condition, got {other:?}"),
447        }
448    }
449
450    #[test]
451    fn after_clause_bumps_lower_bound_by_one_day() {
452        let q = SearchEmailsQuery {
453            filter: Some(SearchEmailsFilterQuery::AfterDate(naive(2026, 1, 15))),
454            sort: None,
455        };
456        let c = build(Some(&q), "mbox".into());
457        assert!(matches!(
458            c.post_filters.as_slice(),
459            [PostFilter::AfterDate(d)] if *d == naive(2026, 1, 15)
460        ));
461        let inner = pluck_user_filter(c.filter);
462        match inner {
463            JmapFilter::Condition(JmapEmailFilter { after, .. }) => {
464                assert_eq!(after.as_deref(), Some("2026-01-16T00:00:00Z"));
465            }
466            other => panic!("expected condition, got {other:?}"),
467        }
468    }
469
470    #[test]
471    fn post_filter_drops_false_positives_for_date_clause() {
472        let post = [PostFilter::Date(naive(2026, 5, 15))];
473        let on_day = envelope_at("2026-05-15T10:00:00+00:00");
474        let next_day = envelope_at("2026-05-16T00:00:00+00:00");
475        assert!(post_match(&on_day, &post));
476        assert!(!post_match(&next_day, &post));
477    }
478
479    #[test]
480    fn post_filter_strict_after() {
481        let post = [PostFilter::AfterDate(naive(2026, 5, 15))];
482        let on_day = envelope_at("2026-05-15T23:59:59+00:00");
483        let next_day = envelope_at("2026-05-16T00:00:00+00:00");
484        assert!(!post_match(&on_day, &post));
485        assert!(post_match(&next_day, &post));
486    }
487
488    #[test]
489    fn flag_clause_sets_has_keyword() {
490        use crate::flag::types::IanaFlag;
491
492        let q = SearchEmailsQuery {
493            filter: Some(SearchEmailsFilterQuery::Flag(Flag::from_iana(
494                IanaFlag::Flagged,
495            ))),
496            sort: None,
497        };
498        let c = build(Some(&q), "mbox".into());
499        let inner = pluck_user_filter(c.filter);
500        match inner {
501            JmapFilter::Condition(JmapEmailFilter { has_keyword, .. }) => {
502                assert_eq!(has_keyword.as_deref(), Some("$flagged"));
503            }
504            other => panic!("expected condition, got {other:?}"),
505        }
506    }
507}