Skip to main content

io_imap/rfc5256/
sort.rs

1//! IMAP SORT coroutine ([`ImapMessageSort`]) with a client-side
2//! fallback.
3//!
4//! Runs the RFC 5256 SORT command, or, when the server lacks the SORT
5//! extension (or the caller opts out via `fallback`), falls back to
6//! SEARCH + FETCH + a local sort. Both paths return the same
7//! `Vec<NonZeroU32>`.
8//!
9//! # Example
10//!
11//! ```rust,no_run
12//! use std::{
13//!     io::{Read, Write},
14//!     net::TcpStream,
15//! };
16//!
17//! use io_imap::{
18//!     codec::{fragmentizer::Fragmentizer, imap_types::core::Vec1},
19//!     coroutine::{ImapCoroutine, ImapCoroutineState, ImapYield},
20//!     rfc5256::sort::{ImapMessageSort, ImapMessageSortOptions},
21//!     types::{
22//!         extensions::sort::{SortCriterion, SortKey},
23//!         search::SearchKey,
24//!     },
25//! };
26//!
27//! // Ready stream needed (TCP-connected, TLS-negotiated, IMAP-authenticated)
28//! let mut stream = TcpStream::connect("localhost:143").unwrap();
29//!
30//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
31//! let mut buf = [0u8; 4096];
32//!
33//! let sort_criteria = Vec1::try_from(vec![SortCriterion {
34//!     reverse: true,
35//!     key: SortKey::Date,
36//! }])
37//! .unwrap();
38//! let search_criteria = Vec1::try_from(vec![SearchKey::All]).unwrap();
39//! let opts = ImapMessageSortOptions::default();
40//! let mut coroutine =
41//!     ImapMessageSort::new(sort_criteria, search_criteria, opts);
42//! let mut arg = None;
43//!
44//! let ids = loop {
45//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
46//!         ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
47//!             stream.write_all(&bytes).unwrap();
48//!         }
49//!         ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
50//!             let n = stream.read(&mut buf).unwrap();
51//!             arg = Some(&buf[..n]);
52//!         }
53//!         ImapCoroutineState::Complete(Ok(ids)) => break ids,
54//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
55//!     }
56//! };
57//!
58//! println!("{ids:?}");
59//! ```
60
61use core::{cmp::Ordering, fmt, mem, num::NonZeroU32, str::from_utf8};
62
63use alloc::{collections::BTreeMap, string::String, string::ToString, vec::Vec};
64
65use chrono::{DateTime, FixedOffset};
66
67use imap_codec::{
68    CommandCodec,
69    fragmentizer::Fragmentizer,
70    imap_types::{
71        command::{Command, CommandBody},
72        core::{Charset, TagGenerator, Vec1},
73        extensions::sort::{SortCriterion, SortKey},
74        fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName},
75        response::{Data, StatusKind, Tagged},
76        search::SearchKey,
77        sequence::SequenceSet,
78    },
79};
80use log::{debug, trace};
81use thiserror::Error;
82
83use crate::{
84    coroutine::*,
85    imap_try,
86    rfc3501::{fetch::*, search::*},
87    send::*,
88};
89
90/// FETCH chunk size for the fallback, matching the legacy imap-client.
91const MAX_CHUNK: usize = 255;
92
93/// Failure causes during the IMAP SORT flow.
94#[derive(Clone, Debug, Error)]
95pub enum ImapMessageSortError {
96    /// The server rejected the SORT command with a NO response.
97    #[error("IMAP SORT failed: NO {0}")]
98    No(String),
99    /// The server rejected the SORT command with a BAD response.
100    #[error("IMAP SORT failed: BAD {0}")]
101    Bad(String),
102    /// The server closed the connection with a BYE response.
103    #[error("IMAP SORT failed: BYE {0}")]
104    Bye(String),
105    /// The server never answered with a tagged response.
106    #[error("IMAP SORT failed: server did not return a tagged response")]
107    MissingTagged,
108    /// The server returned OK without any SORT data.
109    #[error("IMAP SORT failed: server did not return any data")]
110    MissingData,
111    /// A chunk of searched ids could not form a valid sequence set.
112    #[error("IMAP SORT failed: could not build the fetch sequence set")]
113    InvalidSequenceSet,
114    /// The underlying send sub-coroutine failed.
115    #[error("IMAP SORT failed: {0}")]
116    Send(#[from] ImapSendError),
117    /// The SEARCH step of the fallback failed.
118    #[error(transparent)]
119    Search(#[from] ImapMessageSearchError),
120    /// The FETCH step of the fallback failed.
121    #[error(transparent)]
122    Fetch(#[from] ImapMessageFetchError),
123}
124
125/// Options for [`ImapMessageSort::new`].
126#[derive(Clone, Debug, Default, Eq, PartialEq)]
127pub struct ImapMessageSortOptions {
128    /// When `true`, sort UIDs; returned ids are UIDs.
129    pub uid: bool,
130    /// When `true`, skip the SORT command and sort client-side via
131    /// SEARCH + FETCH; defaults to using the server SORT.
132    ///
133    /// The consumer sets this from a SORT capability check (or by
134    /// choice).
135    pub fallback: bool,
136}
137
138/// I/O-free IMAP SORT coroutine with a SEARCH + FETCH client-side fallback.
139///
140/// With `fallback == false` it sends a plain server `SORT`. With `fallback ==
141/// true` it SEARCHes the candidates, FETCHes the sort keys, and sorts locally,
142/// returning the same `Vec<NonZeroU32>` either way.
143pub struct ImapMessageSort {
144    state: State,
145    uid: bool,
146    sort_criteria: Vec1<SortCriterion>,
147    items: Vec<MessageDataItemName<'static>>,
148    remaining: Vec<NonZeroU32>,
149    fetched: BTreeMap<NonZeroU32, Vec1<MessageDataItem<'static>>>,
150}
151
152impl ImapMessageSort {
153    /// Creates a coroutine that sorts the messages matching
154    /// `search_criteria` by `sort_criteria`, server-side or locally.
155    ///
156    /// `opts.fallback` selects the SEARCH + FETCH client-side path and
157    /// `opts.uid` the UID variant of every command involved.
158    pub fn new(
159        sort_criteria: Vec1<SortCriterion>,
160        search_criteria: Vec1<SearchKey<'static>>,
161        opts: ImapMessageSortOptions,
162    ) -> Self {
163        let items = fetch_items(&sort_criteria, opts.uid);
164
165        let state = if opts.fallback {
166            trace!("using IMAP SORT fallback");
167            let search =
168                ImapMessageSearch::new(search_criteria, ImapMessageSearchOptions { uid: opts.uid });
169            State::Search(search)
170        } else {
171            let command = Command {
172                tag: TagGenerator::new().generate(),
173                body: CommandBody::Sort {
174                    sort_criteria: sort_criteria.clone(),
175                    charset: Charset::try_from("UTF-8").unwrap(),
176                    search_criteria,
177                    uid: opts.uid,
178                },
179            };
180
181            trace!("send IMAP command {command:?}");
182
183            State::Sort(ImapSend::new(CommandCodec::new(), command))
184        };
185
186        Self {
187            state,
188            uid: opts.uid,
189            sort_criteria,
190            items,
191            remaining: Vec::new(),
192            fetched: BTreeMap::new(),
193        }
194    }
195
196    /// Builds the next chunked FETCH sub-coroutine, or `None` once every
197    /// searched id has been fetched.
198    fn next_fetch(&mut self) -> Result<Option<ImapMessageFetch>, ImapMessageSortError> {
199        if self.remaining.is_empty() {
200            return Ok(None);
201        }
202
203        let take = self.remaining.len().min(MAX_CHUNK);
204        let chunk: Vec<NonZeroU32> = self.remaining.drain(..take).collect();
205        let sequence_set =
206            SequenceSet::try_from(chunk).map_err(|_| ImapMessageSortError::InvalidSequenceSet)?;
207        let items = MacroOrMessageDataItemNames::MessageDataItemNames(self.items.clone());
208
209        Ok(Some(ImapMessageFetch::new(
210            sequence_set,
211            items,
212            ImapMessageFetchOptions {
213                uid: self.uid,
214                ..Default::default()
215            },
216        )))
217    }
218
219    /// Sorts the fetched messages locally and returns their ids in order.
220    fn local_sort(&mut self) -> Vec<NonZeroU32> {
221        let uid = self.uid;
222        let criteria = self.sort_criteria.clone();
223        let mut entries: Vec<(NonZeroU32, Vec1<MessageDataItem<'static>>)> =
224            mem::take(&mut self.fetched).into_iter().collect();
225
226        entries.sort_by(|(_, a), (_, b)| {
227            for criterion in criteria.as_ref() {
228                let mut cmp = cmp_fetch_items(&criterion.key, a, b);
229
230                if criterion.reverse {
231                    cmp = cmp.reverse();
232                }
233
234                if cmp.is_ne() {
235                    return cmp;
236                }
237            }
238
239            cmp_fetch_items(&SortKey::Date, a, b)
240        });
241
242        entries
243            .into_iter()
244            .filter_map(|(seq, items)| {
245                if uid {
246                    items.as_ref().iter().find_map(|item| match item {
247                        MessageDataItem::Uid(uid) => Some(*uid),
248                        _ => None,
249                    })
250                } else {
251                    Some(seq)
252                }
253            })
254            .collect()
255    }
256}
257
258impl ImapCoroutine for ImapMessageSort {
259    type Yield = ImapYield;
260    type Return = Result<Vec<NonZeroU32>, ImapMessageSortError>;
261
262    fn resume(
263        &mut self,
264        fragmentizer: &mut Fragmentizer,
265        arg: Option<&[u8]>,
266    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
267        loop {
268            match &mut self.state {
269                State::Sort(send) => {
270                    let out = imap_try!(send, fragmentizer, arg);
271
272                    if let Some(bye) = out.bye {
273                        let err = ImapMessageSortError::Bye(bye.text.to_string());
274                        return ImapCoroutineState::Complete(Err(err));
275                    }
276
277                    let Some(Tagged { body, .. }) = out.tagged else {
278                        let err = ImapMessageSortError::MissingTagged;
279                        return ImapCoroutineState::Complete(Err(err));
280                    };
281
282                    let mut ids = None;
283
284                    for data in out.data {
285                        if let Data::Sort(sort_ids, _) = data {
286                            ids = Some(sort_ids);
287                        }
288                    }
289
290                    return match body.kind {
291                        StatusKind::Ok => match ids {
292                            Some(ids) => ImapCoroutineState::Complete(Ok(ids)),
293                            None => {
294                                ImapCoroutineState::Complete(Err(ImapMessageSortError::MissingData))
295                            }
296                        },
297                        StatusKind::No => {
298                            let err = ImapMessageSortError::No(body.text.to_string());
299                            ImapCoroutineState::Complete(Err(err))
300                        }
301                        StatusKind::Bad => {
302                            let err = ImapMessageSortError::Bad(body.text.to_string());
303                            ImapCoroutineState::Complete(Err(err))
304                        }
305                    };
306                }
307                State::Search(search) => {
308                    self.remaining = imap_try!(search, fragmentizer, arg);
309
310                    match self.next_fetch() {
311                        Ok(Some(fetch)) => {
312                            self.state = State::Fetch(fetch);
313                            debug!("{}", self.state);
314                        }
315                        Ok(None) => return ImapCoroutineState::Complete(Ok(Vec::new())),
316                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
317                    }
318                }
319                State::Fetch(fetch) => {
320                    let map = imap_try!(fetch, fragmentizer, arg);
321                    self.fetched.extend(map);
322
323                    match self.next_fetch() {
324                        Ok(Some(fetch)) => {
325                            self.state = State::Fetch(fetch);
326                            debug!("{}", self.state);
327                        }
328                        Ok(None) => return ImapCoroutineState::Complete(Ok(self.local_sort())),
329                        Err(err) => return ImapCoroutineState::Complete(Err(err)),
330                    }
331                }
332            }
333        }
334    }
335}
336
337enum State {
338    Sort(ImapSend<CommandCodec>),
339    Search(ImapMessageSearch),
340    Fetch(ImapMessageFetch),
341}
342
343impl fmt::Display for State {
344    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
345        match self {
346            Self::Sort(_) => f.write_str("send sort command"),
347            Self::Search(_) => f.write_str("search for candidates"),
348            Self::Fetch(_) => f.write_str("fetch sort keys"),
349        }
350    }
351}
352
353/// The FETCH items needed to sort by `sort_criteria` locally.
354///
355/// Display keys have no comparable data and are skipped; UID is added
356/// in UID mode to recover the sorted ids; an Envelope is always
357/// present so the Date tie-break works.
358fn fetch_items(
359    sort_criteria: &Vec1<SortCriterion>,
360    uid: bool,
361) -> Vec<MessageDataItemName<'static>> {
362    let mut items: Vec<MessageDataItemName<'static>> = Vec::new();
363
364    for criterion in sort_criteria.as_ref() {
365        let item = match &criterion.key {
366            SortKey::Arrival => Some(MessageDataItemName::InternalDate),
367            SortKey::Size => Some(MessageDataItemName::Rfc822Size),
368            SortKey::Cc | SortKey::Date | SortKey::From | SortKey::Subject | SortKey::To => {
369                Some(MessageDataItemName::Envelope)
370            }
371            SortKey::DisplayFrom | SortKey::DisplayTo => None,
372        };
373
374        if let Some(item) = item {
375            if !items.contains(&item) {
376                items.push(item);
377            }
378        }
379    }
380
381    if !items.contains(&MessageDataItemName::Envelope) {
382        items.push(MessageDataItemName::Envelope);
383    }
384
385    if uid && !items.contains(&MessageDataItemName::Uid) {
386        items.push(MessageDataItemName::Uid);
387    }
388
389    items
390}
391
392/// Compares two fetched messages by a single sort key.
393///
394/// From/To/Cc/Display fall through to `Equal`: imap-types `Address`
395/// has no `Ord`, so (matching himalaya 1.2.0) those keys are a no-op
396/// and defer to the Date tie-break.
397fn cmp_fetch_items(
398    key: &SortKey,
399    a: &Vec1<MessageDataItem<'static>>,
400    b: &Vec1<MessageDataItem<'static>>,
401) -> Ordering {
402    match key {
403        SortKey::Arrival => {
404            let a = a.as_ref().iter().find_map(|item| match item {
405                MessageDataItem::InternalDate(date) => Some(date.as_ref()),
406                _ => None,
407            });
408            let b = b.as_ref().iter().find_map(|item| match item {
409                MessageDataItem::InternalDate(date) => Some(date.as_ref()),
410                _ => None,
411            });
412            a.cmp(&b)
413        }
414        SortKey::Size => {
415            let a = a.as_ref().iter().find_map(|item| match item {
416                MessageDataItem::Rfc822Size(size) => Some(size),
417                _ => None,
418            });
419            let b = b.as_ref().iter().find_map(|item| match item {
420                MessageDataItem::Rfc822Size(size) => Some(size),
421                _ => None,
422            });
423            a.cmp(&b)
424        }
425        SortKey::Date => {
426            // The ENVELOPE `date` is the raw `Date:` header (RFC 5322),
427            // so it must be parsed to an instant before comparing: a
428            // lexical string compare orders by weekday name, not time.
429            let a = a.as_ref().iter().find_map(|item| match item {
430                MessageDataItem::Envelope(envelope) => envelope.date.0.as_ref().map(AsRef::as_ref),
431                _ => None,
432            });
433            let b = b.as_ref().iter().find_map(|item| match item {
434                MessageDataItem::Envelope(envelope) => envelope.date.0.as_ref().map(AsRef::as_ref),
435                _ => None,
436            });
437            date_sort_key(a).cmp(&date_sort_key(b))
438        }
439        SortKey::Subject => {
440            let a = a.as_ref().iter().find_map(|item| match item {
441                MessageDataItem::Envelope(envelope) => {
442                    envelope.subject.0.as_ref().map(AsRef::as_ref)
443                }
444                _ => None,
445            });
446            let b = b.as_ref().iter().find_map(|item| match item {
447                MessageDataItem::Envelope(envelope) => {
448                    envelope.subject.0.as_ref().map(AsRef::as_ref)
449                }
450                _ => None,
451            });
452            a.cmp(&b)
453        }
454        SortKey::Cc | SortKey::From | SortKey::To | SortKey::DisplayFrom | SortKey::DisplayTo => {
455            Ordering::Equal
456        }
457    }
458}
459
460/// Parses a raw `Date:` header (RFC 5322 / 2822) into a comparable
461/// instant, so `SortKey::Date` orders chronologically rather than by the
462/// header's leading weekday. The ENVELOPE date arrives as bytes; a
463/// non-UTF-8, unparsable, or absent header yields `None`, which sorts
464/// before any real date.
465fn date_sort_key(raw: Option<&[u8]>) -> Option<DateTime<FixedOffset>> {
466    let raw = from_utf8(raw?).ok()?;
467    DateTime::parse_from_rfc2822(raw).ok()
468}
469
470#[cfg(test)]
471mod tests {
472    use core::str;
473
474    use alloc::{borrow::ToOwned, format, vec, vec::Vec};
475
476    use crate::rfc5256::sort::*;
477
478    fn arrival() -> Vec1<SortCriterion> {
479        Vec1::try_from(vec![SortCriterion {
480            reverse: false,
481            key: SortKey::Arrival,
482        }])
483        .expect("one sort criterion")
484    }
485
486    fn search_criteria() -> Vec1<SearchKey<'static>> {
487        Vec1::try_from(vec![SearchKey::All]).expect("one search criterion")
488    }
489
490    fn first_word(line: &str) -> &str {
491        line.split_whitespace()
492            .next()
493            .expect("first whitespace-separated token")
494    }
495
496    #[test]
497    fn sort_success_returns_ids() {
498        let mut sort = ImapMessageSort::new(
499            arrival(),
500            search_criteria(),
501            ImapMessageSortOptions::default(),
502        );
503        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
504
505        let bytes = wants_write(&mut sort, &mut frag, None);
506        let line = str::from_utf8(&bytes).expect("utf8 command");
507        let tag = first_word(line).to_owned();
508        assert!(line.contains("SORT "));
509        assert!(!line.contains("SEARCH"));
510
511        wants_read(&mut sort, &mut frag, None);
512
513        let reply = format!("* SORT 3 1 2\r\n{tag} OK SORT completed\r\n");
514        let ids = complete_ok(&mut sort, &mut frag, Some(reply.as_bytes()));
515        assert_eq!(
516            vec![nz(3), nz(1), nz(2)],
517            ids,
518            "server order preserved verbatim"
519        );
520    }
521
522    #[test]
523    fn sort_uid_variant_sends_uid_sort() {
524        let mut sort = ImapMessageSort::new(
525            arrival(),
526            search_criteria(),
527            ImapMessageSortOptions {
528                uid: true,
529                ..Default::default()
530            },
531        );
532        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
533
534        let bytes = wants_write(&mut sort, &mut frag, None);
535        let line = str::from_utf8(&bytes).expect("utf8 command");
536        assert!(line.contains("UID SORT "));
537    }
538
539    #[test]
540    fn sort_missing_data_returns_missing_data_error() {
541        let mut sort = ImapMessageSort::new(
542            arrival(),
543            search_criteria(),
544            ImapMessageSortOptions::default(),
545        );
546        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
547
548        let bytes = wants_write(&mut sort, &mut frag, None);
549        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
550
551        wants_read(&mut sort, &mut frag, None);
552
553        let reply = format!("{tag} OK SORT completed\r\n");
554        let err = complete_err(&mut sort, &mut frag, reply.as_bytes());
555        assert!(matches!(err, ImapMessageSortError::MissingData));
556    }
557
558    #[test]
559    fn sort_tagged_no_returns_no_error() {
560        let mut sort = ImapMessageSort::new(
561            arrival(),
562            search_criteria(),
563            ImapMessageSortOptions::default(),
564        );
565        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
566
567        let bytes = wants_write(&mut sort, &mut frag, None);
568        let tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
569
570        wants_read(&mut sort, &mut frag, None);
571
572        let reply = format!("{tag} NO no mailbox selected\r\n");
573        let err = complete_err(&mut sort, &mut frag, reply.as_bytes());
574        let ImapMessageSortError::No(text) = err else {
575            panic!("expected ImapMessageSortError::No, got {err:?}");
576        };
577        assert_eq!(text, "no mailbox selected");
578    }
579
580    #[test]
581    fn fallback_searches_then_fetches_then_sorts() {
582        let mut sort = ImapMessageSort::new(
583            arrival(),
584            search_criteria(),
585            ImapMessageSortOptions {
586                uid: true,
587                fallback: true,
588            },
589        );
590        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
591
592        let bytes = wants_write(&mut sort, &mut frag, None);
593        let line = str::from_utf8(&bytes).expect("utf8 command");
594        let search_tag = first_word(line).to_owned();
595        assert!(line.contains("UID SEARCH"));
596
597        wants_read(&mut sort, &mut frag, None);
598
599        let search_reply = format!("* SEARCH 1 2\r\n{search_tag} OK SEARCH completed\r\n");
600        let bytes = wants_write(&mut sort, &mut frag, Some(search_reply.as_bytes()));
601        let line = str::from_utf8(&bytes).expect("utf8 command");
602        let fetch_tag = first_word(line).to_owned();
603        assert!(line.contains("UID FETCH"));
604        assert!(line.contains("INTERNALDATE"));
605        assert!(line.contains("UID"));
606
607        wants_read(&mut sort, &mut frag, None);
608
609        // NOTE: UID 1 arrived later than UID 2, so arrival-ascending
610        // sorts to [2, 1].
611        let fetch_reply = format!(
612            "* 1 FETCH (UID 1 INTERNALDATE \"02-Feb-2021 00:00:00 +0000\")\r\n\
613             * 2 FETCH (UID 2 INTERNALDATE \"01-Jan-2020 00:00:00 +0000\")\r\n\
614             {fetch_tag} OK FETCH completed\r\n"
615        );
616        let ids = complete_ok(&mut sort, &mut frag, Some(fetch_reply.as_bytes()));
617        assert_eq!(vec![nz(2), nz(1)], ids);
618    }
619
620    #[test]
621    fn fallback_reverse_flips_order() {
622        let criteria = Vec1::try_from(vec![SortCriterion {
623            reverse: true,
624            key: SortKey::Arrival,
625        }])
626        .expect("one criterion");
627        let mut sort = ImapMessageSort::new(
628            criteria,
629            search_criteria(),
630            ImapMessageSortOptions {
631                uid: true,
632                fallback: true,
633            },
634        );
635        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
636
637        let bytes = wants_write(&mut sort, &mut frag, None);
638        let search_tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
639        wants_read(&mut sort, &mut frag, None);
640
641        let search_reply = format!("* SEARCH 1 2\r\n{search_tag} OK SEARCH completed\r\n");
642        let bytes = wants_write(&mut sort, &mut frag, Some(search_reply.as_bytes()));
643        let fetch_tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
644        wants_read(&mut sort, &mut frag, None);
645
646        let fetch_reply = format!(
647            "* 1 FETCH (UID 1 INTERNALDATE \"02-Feb-2021 00:00:00 +0000\")\r\n\
648             * 2 FETCH (UID 2 INTERNALDATE \"01-Jan-2020 00:00:00 +0000\")\r\n\
649             {fetch_tag} OK FETCH completed\r\n"
650        );
651        let ids = complete_ok(&mut sort, &mut frag, Some(fetch_reply.as_bytes()));
652        assert_eq!(vec![nz(1), nz(2)], ids);
653    }
654
655    #[test]
656    fn fallback_empty_search_returns_empty_without_fetch() {
657        let mut sort = ImapMessageSort::new(
658            arrival(),
659            search_criteria(),
660            ImapMessageSortOptions {
661                uid: true,
662                fallback: true,
663            },
664        );
665        let mut frag = Fragmentizer::new(50 * 1024 * 1024);
666
667        let bytes = wants_write(&mut sort, &mut frag, None);
668        let search_tag = first_word(str::from_utf8(&bytes).expect("utf8 command")).to_owned();
669        wants_read(&mut sort, &mut frag, None);
670
671        let search_reply = format!("* SEARCH\r\n{search_tag} OK SEARCH completed\r\n");
672        let ids = complete_ok(&mut sort, &mut frag, Some(search_reply.as_bytes()));
673        assert!(ids.is_empty());
674    }
675
676    #[test]
677    fn date_sort_key_is_chronological_not_lexical() {
678        // Same format iCloud fixtures use: the weekday leads the string,
679        // so a lexical compare orders by weekday name, not by instant.
680        let mon = date_sort_key(Some(b"Mon, 13 Jul 2026 09:00:00 +0200"));
681        let fri = date_sort_key(Some(b"Fri, 17 Jul 2026 16:20:00 +0200"));
682
683        // Chronologically 13 Jul precedes 17 Jul...
684        assert!(mon < fri, "13 Jul must sort before 17 Jul");
685        // ...even though lexically the raw headers compare the other way.
686        assert!(*b"Fri, 17 Jul 2026 16:20:00 +0200" < *b"Mon, 13 Jul 2026 09:00:00 +0200");
687
688        // Offsets are honoured: 10:00 +0000 is after 11:00 +0200 (09:00Z).
689        let utc = date_sort_key(Some(b"Mon, 13 Jul 2026 10:00:00 +0000"));
690        let cest = date_sort_key(Some(b"Mon, 13 Jul 2026 11:00:00 +0200"));
691        assert!(cest < utc, "09:00Z must sort before 10:00Z");
692
693        // Absent or unparsable dates fall to the bottom, deterministically.
694        assert_eq!(date_sort_key(None), None);
695        assert_eq!(date_sort_key(Some(b"not a date")), None);
696        assert!(date_sort_key(None) < mon);
697    }
698
699    fn nz(n: u32) -> NonZeroU32 {
700        NonZeroU32::new(n).expect("non-zero")
701    }
702
703    fn wants_write(
704        cor: &mut ImapMessageSort,
705        frag: &mut Fragmentizer,
706        arg: Option<&[u8]>,
707    ) -> Vec<u8> {
708        match cor.resume(frag, arg) {
709            ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => bytes,
710            state => panic!("expected WantsWrite, got {state:?}"),
711        }
712    }
713
714    fn wants_read(cor: &mut ImapMessageSort, frag: &mut Fragmentizer, arg: Option<&[u8]>) {
715        match cor.resume(frag, arg) {
716            ImapCoroutineState::Yielded(ImapYield::WantsRead) => {}
717            state => panic!("expected WantsRead, got {state:?}"),
718        }
719    }
720
721    fn complete_ok(
722        cor: &mut ImapMessageSort,
723        frag: &mut Fragmentizer,
724        arg: Option<&[u8]>,
725    ) -> Vec<NonZeroU32> {
726        match cor.resume(frag, arg) {
727            ImapCoroutineState::Complete(Ok(value)) => value,
728            state => panic!("expected Complete(Ok), got {state:?}"),
729        }
730    }
731
732    fn complete_err(
733        cor: &mut ImapMessageSort,
734        frag: &mut Fragmentizer,
735        reply: &[u8],
736    ) -> ImapMessageSortError {
737        match cor.resume(frag, Some(reply)) {
738            ImapCoroutineState::Complete(Err(err)) => err,
739            state => panic!("expected Complete(Err), got {state:?}"),
740        }
741    }
742}