Skip to main content

io_imap/
watch.rs

1//! IMAP single-mailbox watcher: IDLE (RFC 2177) for the wake signal,
2//! EXAMINE (QRESYNC) (RFC 7162) for UID-keyed deltas.
3//!
4//! The mailbox is opened with EXAMINE, not SELECT, so the session is
5//! **read-only**: the watcher never writes (no flag changes, no expunge),
6//! and it avoids SELECT's `\Recent` reset on every re-open.
7//!
8//! QRESYNC: <https://www.rfc-editor.org/rfc/rfc7162>
9//!
10//! ```text
11//! EXAMINE (CONDSTORE) → FETCH 1:* (UID FLAGS) [seed shadow]
12//!     → IDLE → EXAMINE (QRESYNC) → emit deltas → IDLE → ...
13//! ```
14//!
15//! Connection is dedicated. Flip the shared [`AtomicBool`] to wind
16//! down cleanly.
17//!
18//! # Example
19//!
20//! ```rust,no_run
21//! use core::sync::atomic::AtomicBool;
22//! use std::{
23//!     io::{Read, Write},
24//!     net::TcpStream,
25//!     sync::Arc,
26//! };
27//!
28//! use io_imap::{
29//!     codec::fragmentizer::Fragmentizer,
30//!     coroutine::{ImapCoroutine, ImapCoroutineState},
31//!     types::response::Capability,
32//!     watch::{ImapMailboxWatch, ImapMailboxWatchYield},
33//! };
34//!
35//! // Ready stream needed (TCP-connected, TLS-negotiated, IMAP-authenticated)
36//! let mut stream = TcpStream::connect("localhost:143").unwrap();
37//!
38//! let mut fragmentizer = Fragmentizer::new(50 * 1024 * 1024);
39//! let mut buf = [0u8; 4096];
40//!
41//! let capability = [Capability::QResync];
42//! let mailbox = "INBOX".try_into().unwrap();
43//! let shutdown = Arc::new(AtomicBool::new(false));
44//! let mut coroutine =
45//!     ImapMailboxWatch::new(&capability, mailbox, shutdown.clone()).unwrap();
46//! let mut arg = None;
47//!
48//! loop {
49//!     match coroutine.resume(&mut fragmentizer, arg.take()) {
50//!         ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(bytes)) => {
51//!             stream.write_all(&bytes).unwrap();
52//!         }
53//!         ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead) => {
54//!             let n = stream.read(&mut buf).unwrap();
55//!             arg = Some(&buf[..n]);
56//!         }
57//!         ImapCoroutineState::Yielded(ImapMailboxWatchYield::Event(event)) => {
58//!             println!("{event:?}");
59//!         }
60//!         ImapCoroutineState::Complete(Ok(())) => break,
61//!         ImapCoroutineState::Complete(Err(err)) => panic!("{err}"),
62//!     }
63//! }
64//! ```
65
66use core::{
67    mem,
68    num::{NonZeroU32, NonZeroU64},
69    sync::atomic::{AtomicBool, Ordering},
70};
71
72use alloc::{
73    collections::{BTreeMap, VecDeque},
74    string::String,
75    sync::Arc,
76    vec,
77    vec::Vec,
78};
79
80use imap_codec::{
81    fragmentizer::Fragmentizer,
82    imap_types::{
83        command::SelectParameter,
84        core::{Atom, Vec1},
85        extensions::enable::CapabilityEnable,
86        fetch::{MacroOrMessageDataItemNames, MessageDataItem, MessageDataItemName},
87        flag::{Flag, FlagFetch},
88        mailbox::Mailbox,
89        response::Capability,
90        sequence::SequenceSet,
91    },
92};
93use log::{debug, trace};
94use thiserror::Error;
95
96use crate::{
97    coroutine::*,
98    rfc2177::idle::{ImapIdle, ImapIdleError, ImapIdleOptions, ImapIdleYield},
99    rfc3501::{
100        examine::{ImapMailboxExamine, ImapMailboxExamineError, ImapMailboxExamineOptions},
101        fetch::{ImapMessageFetch, ImapMessageFetchError, ImapMessageFetchOptions},
102        select::ImapMailboxSelectData,
103    },
104    rfc5161::enable::{ImapExtensionEnable, ImapExtensionEnableError},
105};
106
107/// UID-keyed mailbox change emitted by the watcher.
108///
109/// `FlagsAdded`/`FlagsRemoved` are pre-diffed against the internal
110/// shadow; each `flags` vector lists only the changed flags.
111#[derive(Clone, Debug)]
112pub enum ImapMailboxWatchEvent {
113    /// A message appeared in the mailbox.
114    EnvelopeAdded {
115        /// The UID of the new message.
116        uid: NonZeroU32,
117        /// The FETCH items announcing the message.
118        items: Vec<MessageDataItem<'static>>,
119    },
120    /// Flags were set on an existing message.
121    FlagsAdded {
122        /// The UID of the changed message.
123        uid: NonZeroU32,
124        /// The flags that were added.
125        flags: Vec<Flag<'static>>,
126    },
127    /// Flags were cleared on an existing message.
128    FlagsRemoved {
129        /// The UID of the changed message.
130        uid: NonZeroU32,
131        /// The flags that were removed.
132        flags: Vec<Flag<'static>>,
133    },
134    /// A message left the mailbox (expunged or moved away).
135    EnvelopeRemoved {
136        /// The UID of the removed message.
137        uid: NonZeroU32,
138    },
139}
140
141/// Failure causes during the mailbox watch flow.
142#[derive(Debug, Error)]
143pub enum ImapMailboxWatchError {
144    /// The capability list given to `new` lacks QRESYNC.
145    #[error("IMAP server does not advertise QRESYNC")]
146    QresyncUnsupported,
147    /// The EXAMINE response carried no UIDVALIDITY, so deltas cannot be
148    /// keyed safely.
149    #[error("IMAP server did not return UIDVALIDITY in EXAMINE response")]
150    MissingUidValidity,
151    /// The EXAMINE response carried no HIGHESTMODSEQ, so there is no
152    /// resync point.
153    #[error("IMAP server did not return HIGHESTMODSEQ in EXAMINE response")]
154    MissingHighestModSeq,
155    /// The baseline `1:*` sequence set failed to parse.
156    #[error("Invalid `1:*` sequence set: {0}")]
157    InvalidSequenceSet(String),
158    /// The initial or QRESYNC EXAMINE failed.
159    #[error("IMAP EXAMINE error")]
160    Examine(#[from] ImapMailboxExamineError),
161    /// The baseline FETCH failed.
162    #[error("IMAP FETCH error")]
163    Fetch(#[from] ImapMessageFetchError),
164    /// The IDLE wake-loop failed.
165    #[error("IMAP IDLE error")]
166    Idle(#[from] ImapIdleError),
167    /// The ENABLE QRESYNC round failed.
168    #[error("IMAP ENABLE error")]
169    Enable(#[from] ImapExtensionEnableError),
170}
171
172/// Yield variants from the mailbox watcher.
173#[derive(Debug)]
174pub enum ImapMailboxWatchYield {
175    /// The caller reads from its stream and resumes with the bytes.
176    WantsRead,
177    /// The caller writes the given bytes to its stream and resumes.
178    WantsWrite(Vec<u8>),
179    /// A mailbox change to consume; the watcher keeps running.
180    Event(ImapMailboxWatchEvent),
181}
182
183enum State {
184    EnableQresync(ImapExtensionEnable),
185    ExamineInitial(ImapMailboxExamine),
186    FetchBaseline(ImapMessageFetch),
187    BeginIdle,
188    Idle(ImapIdle),
189    ExamineQresync(ImapMailboxExamine),
190    EmitDeltas,
191    Terminal,
192}
193
194/// I/O-free IDLE+QRESYNC mailbox watcher.
195pub struct ImapMailboxWatch {
196    state: State,
197    shutdown: Arc<AtomicBool>,
198    idle_done: Arc<AtomicBool>,
199    idle_saw_data: bool,
200    mailbox: Mailbox<'static>,
201    uid_validity: Option<NonZeroU32>,
202    highest_mod_seq: u64,
203    shadow: BTreeMap<NonZeroU32, Vec<Flag<'static>>>,
204    pending: VecDeque<ImapMailboxWatchEvent>,
205}
206
207impl ImapMailboxWatch {
208    /// Errors with `QresyncUnsupported` when `capability` lacks QRESYNC.
209    pub fn new(
210        capability: &[Capability<'static>],
211        mailbox: Mailbox<'static>,
212        shutdown: Arc<AtomicBool>,
213    ) -> Result<Self, ImapMailboxWatchError> {
214        if !capability.contains(&Capability::QResync) {
215            return Err(ImapMailboxWatchError::QresyncUnsupported);
216        }
217
218        // NOTE: RFC 7162 §3.1: QRESYNC implies CONDSTORE, but pass
219        // both since some servers only echo CONDSTORE in ENABLED.
220        let condstore = CapabilityEnable::CondStore;
221        // NOTE: QRESYNC is not in the typed enum, route via Atom.
222        let qresync = CapabilityEnable::from(
223            Atom::try_from("QRESYNC").expect("`QRESYNC` is a syntactically valid IMAP atom"),
224        );
225        let capabilities =
226            Vec1::try_from(vec![condstore, qresync]).expect("two capabilities is non-empty");
227        let enable = ImapExtensionEnable::new(capabilities);
228
229        Ok(Self {
230            state: State::EnableQresync(enable),
231            shutdown,
232            idle_done: Arc::new(AtomicBool::new(false)),
233            idle_saw_data: false,
234            mailbox,
235            uid_validity: None,
236            highest_mod_seq: 0,
237            shadow: BTreeMap::new(),
238            pending: VecDeque::new(),
239        })
240    }
241
242    fn compute_deltas(&mut self, data: &ImapMailboxSelectData) {
243        for uid in &data.vanished_earlier {
244            if self.shadow.remove(uid).is_some() {
245                self.pending
246                    .push_back(ImapMailboxWatchEvent::EnvelopeRemoved { uid: *uid });
247            }
248        }
249
250        for fetch in &data.changed {
251            let items_vec: Vec<MessageDataItem<'static>> =
252                fetch.items.clone().into_inner().into_iter().collect();
253            let (uid_opt, new_flags) = extract_uid_flags(&items_vec);
254            let Some(uid) = uid_opt else {
255                continue;
256            };
257
258            match self.shadow.get(&uid).cloned() {
259                None => {
260                    self.shadow.insert(uid, new_flags);
261                    self.pending
262                        .push_back(ImapMailboxWatchEvent::EnvelopeAdded {
263                            uid,
264                            items: items_vec,
265                        });
266                }
267                Some(old_flags) => {
268                    let added: Vec<Flag<'static>> = new_flags
269                        .iter()
270                        .filter(|f| !old_flags.contains(f))
271                        .cloned()
272                        .collect();
273                    let removed: Vec<Flag<'static>> = old_flags
274                        .iter()
275                        .filter(|f| !new_flags.contains(f))
276                        .cloned()
277                        .collect();
278                    self.shadow.insert(uid, new_flags);
279                    if !added.is_empty() {
280                        self.pending
281                            .push_back(ImapMailboxWatchEvent::FlagsAdded { uid, flags: added });
282                    }
283                    if !removed.is_empty() {
284                        self.pending.push_back(ImapMailboxWatchEvent::FlagsRemoved {
285                            uid,
286                            flags: removed,
287                        });
288                    }
289                }
290            }
291        }
292    }
293}
294
295impl ImapCoroutine for ImapMailboxWatch {
296    type Yield = ImapMailboxWatchYield;
297    type Return = Result<(), ImapMailboxWatchError>;
298
299    fn resume(
300        &mut self,
301        fragmentizer: &mut Fragmentizer,
302        mut arg: Option<&[u8]>,
303    ) -> ImapCoroutineState<Self::Yield, Self::Return> {
304        if self.shutdown.load(Ordering::SeqCst) {
305            self.idle_done.store(true, Ordering::SeqCst);
306        }
307
308        loop {
309            let state = mem::replace(&mut self.state, State::Terminal);
310
311            match state {
312                State::EnableQresync(mut enable) => match enable.resume(fragmentizer, arg.take()) {
313                    ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
314                        self.state = State::EnableQresync(enable);
315                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead);
316                    }
317                    ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
318                        self.state = State::EnableQresync(enable);
319                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(
320                            bytes,
321                        ));
322                    }
323                    ImapCoroutineState::Complete(Ok(enabled)) => {
324                        debug!("enabled qresync");
325                        trace!("{enabled:?}");
326                        let parameters = vec![SelectParameter::CondStore];
327                        let examine = ImapMailboxExamine::new(
328                            self.mailbox.clone(),
329                            ImapMailboxExamineOptions { parameters },
330                        );
331                        self.state = State::ExamineInitial(examine);
332                    }
333                    ImapCoroutineState::Complete(Err(err)) => {
334                        return ImapCoroutineState::Complete(Err(err.into()));
335                    }
336                },
337
338                State::ExamineInitial(mut examine) => {
339                    match examine.resume(fragmentizer, arg.take()) {
340                        ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
341                            self.state = State::ExamineInitial(examine);
342                            return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead);
343                        }
344                        ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
345                            self.state = State::ExamineInitial(examine);
346                            return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(
347                                bytes,
348                            ));
349                        }
350                        ImapCoroutineState::Complete(Ok(data)) => {
351                            let Some(uid_validity) = data.uid_validity else {
352                                return ImapCoroutineState::Complete(Err(
353                                    ImapMailboxWatchError::MissingUidValidity,
354                                ));
355                            };
356                            let Some(highest_mod_seq) = data.highest_mod_seq else {
357                                return ImapCoroutineState::Complete(Err(
358                                    ImapMailboxWatchError::MissingHighestModSeq,
359                                ));
360                            };
361
362                            self.uid_validity = Some(uid_validity);
363                            self.highest_mod_seq = highest_mod_seq;
364                            debug!("examined mailbox with condstore");
365                            trace!("uid_validity: {uid_validity}");
366                            trace!("highest_mod_seq: {highest_mod_seq}");
367
368                            let sequence_set: SequenceSet = match "1:*".try_into() {
369                                Ok(s) => s,
370                                Err(_) => {
371                                    return ImapCoroutineState::Complete(Err(
372                                        ImapMailboxWatchError::InvalidSequenceSet("1:*".into()),
373                                    ));
374                                }
375                            };
376                            let item_names =
377                                MacroOrMessageDataItemNames::MessageDataItemNames(vec![
378                                    MessageDataItemName::Uid,
379                                    MessageDataItemName::Flags,
380                                ]);
381                            let fetch = ImapMessageFetch::new(
382                                sequence_set,
383                                item_names,
384                                ImapMessageFetchOptions::default(),
385                            );
386                            self.state = State::FetchBaseline(fetch);
387                        }
388                        ImapCoroutineState::Complete(Err(err)) => {
389                            return ImapCoroutineState::Complete(Err(err.into()));
390                        }
391                    }
392                }
393
394                State::FetchBaseline(mut fetch) => match fetch.resume(fragmentizer, arg.take()) {
395                    ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
396                        self.state = State::FetchBaseline(fetch);
397                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead);
398                    }
399                    ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
400                        self.state = State::FetchBaseline(fetch);
401                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(
402                            bytes,
403                        ));
404                    }
405                    ImapCoroutineState::Complete(Ok(data)) => {
406                        for (_seq, items) in data {
407                            let items_vec = items.into_inner();
408                            if let (Some(uid), flags) = extract_uid_flags(&items_vec) {
409                                self.shadow.insert(uid, flags);
410                            }
411                        }
412                        debug!("seeded baseline shadow");
413                        trace!("uids: {}", self.shadow.len());
414                        self.state = State::BeginIdle;
415                    }
416                    ImapCoroutineState::Complete(Err(err)) => {
417                        return ImapCoroutineState::Complete(Err(err.into()));
418                    }
419                },
420
421                State::BeginIdle => {
422                    if self.shutdown.load(Ordering::SeqCst) {
423                        return ImapCoroutineState::Complete(Ok(()));
424                    }
425
426                    self.idle_done.store(false, Ordering::SeqCst);
427                    self.idle_saw_data = false;
428                    let idle = ImapIdle::new(self.idle_done.clone(), ImapIdleOptions::default());
429                    self.state = State::Idle(idle);
430                }
431
432                State::Idle(mut idle) => match idle.resume(fragmentizer, arg.take()) {
433                    ImapCoroutineState::Yielded(ImapIdleYield::Event(_)) => {
434                        debug!("idle saw untagged data");
435                        self.idle_saw_data = true;
436                        self.idle_done.store(true, Ordering::SeqCst);
437                        self.state = State::Idle(idle);
438                    }
439                    ImapCoroutineState::Yielded(ImapIdleYield::WantsRead) => {
440                        self.state = State::Idle(idle);
441                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead);
442                    }
443                    ImapCoroutineState::Yielded(ImapIdleYield::WantsWrite(bytes)) => {
444                        self.state = State::Idle(idle);
445                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(
446                            bytes,
447                        ));
448                    }
449                    ImapCoroutineState::Complete(Ok(())) => {
450                        if self.shutdown.load(Ordering::SeqCst) {
451                            return ImapCoroutineState::Complete(Ok(()));
452                        }
453
454                        if self.idle_saw_data {
455                            // NOTE: uid_validity is set by ExamineInitial.
456                            let uid_validity = self.uid_validity.unwrap();
457                            let modseq = NonZeroU64::new(self.highest_mod_seq)
458                                .unwrap_or_else(|| NonZeroU64::new(1).expect("1 is non-zero"));
459                            let parameters = vec![SelectParameter::QResync {
460                                uid_validity,
461                                mod_sequence_value: modseq,
462                                known_uids: None,
463                                seq_match_data: None,
464                            }];
465                            let examine = ImapMailboxExamine::new(
466                                self.mailbox.clone(),
467                                ImapMailboxExamineOptions { parameters },
468                            );
469                            self.state = State::ExamineQresync(examine);
470                        } else {
471                            debug!("idle timed out with no data, restarting");
472                            self.state = State::BeginIdle;
473                        }
474                    }
475                    ImapCoroutineState::Complete(Err(err)) => {
476                        return ImapCoroutineState::Complete(Err(err.into()));
477                    }
478                },
479
480                State::ExamineQresync(mut examine) => {
481                    match examine.resume(fragmentizer, arg.take()) {
482                        ImapCoroutineState::Yielded(ImapYield::WantsRead) => {
483                            self.state = State::ExamineQresync(examine);
484                            return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsRead);
485                        }
486                        ImapCoroutineState::Yielded(ImapYield::WantsWrite(bytes)) => {
487                            self.state = State::ExamineQresync(examine);
488                            return ImapCoroutineState::Yielded(ImapMailboxWatchYield::WantsWrite(
489                                bytes,
490                            ));
491                        }
492                        ImapCoroutineState::Complete(Ok(data)) => {
493                            self.compute_deltas(&data);
494                            if let Some(new_modseq) = data.highest_mod_seq {
495                                self.highest_mod_seq = new_modseq;
496                            }
497                            self.state = State::EmitDeltas;
498                        }
499                        ImapCoroutineState::Complete(Err(err)) => {
500                            return ImapCoroutineState::Complete(Err(err.into()));
501                        }
502                    }
503                }
504
505                State::EmitDeltas => {
506                    if let Some(event) = self.pending.pop_front() {
507                        self.state = State::EmitDeltas;
508                        return ImapCoroutineState::Yielded(ImapMailboxWatchYield::Event(event));
509                    }
510                    self.state = State::BeginIdle;
511                }
512
513                State::Terminal => {
514                    self.state = State::Terminal;
515                    return ImapCoroutineState::Complete(Ok(()));
516                }
517            }
518        }
519    }
520}
521
522/// Extract the UID and flag list from a single FETCH; preserves wire
523/// order, drops non-`Flag` variants of [`FlagFetch`].
524fn extract_uid_flags(
525    items: &[MessageDataItem<'static>],
526) -> (Option<NonZeroU32>, Vec<Flag<'static>>) {
527    let mut uid = None;
528    let mut flags = Vec::new();
529    for item in items {
530        match item {
531            MessageDataItem::Uid(u) => uid = Some(*u),
532            MessageDataItem::Flags(fs) => {
533                flags = fs
534                    .iter()
535                    .filter_map(|f| match f {
536                        FlagFetch::Flag(flag) => Some(flag.clone()),
537                        _ => None,
538                    })
539                    .collect();
540            }
541            _ => {}
542        }
543    }
544    (uid, flags)
545}