1use 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#[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#[derive(Clone, Debug)]
58pub enum PostFilter {
59 Date(NaiveDate),
60 AfterDate(NaiveDate),
61}
62
63pub 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
121struct Converted {
123 filter: JmapFilter<JmapEmailFilter>,
124 sort: Vec<JmapEmailComparator>,
125 post_filters: Vec<PostFilter>,
126}
127
128fn 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
158fn 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 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 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
220fn 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 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}