Skip to main content

io_email/envelope/jmap/
watch.rs

1//! JMAP watch-mailbox coroutine: EventSource (closeafter=state) +
2//! Email/changes + Email/get diffed against an in-memory shadow.
3//!
4//! State machine: Subscribing → FetchingChanges → FetchingEmails →
5//! Emitting → back to Subscribing. Shutdown via the caller-owned
6//! [`Arc<AtomicBool>`] polled at every resume.
7//!
8//! Bootstrap cycle (`sinceState = ""`) returns the full inventory as
9//! `created`; events from that cycle populate the shadow silently.
10//!
11//! # Example
12//!
13//! ```rust,ignore
14//! use io_email::envelope::jmap::watch::JmapWatchMailbox;
15//!
16//! let cor = JmapWatchMailbox::new(&session, &auth, "mailbox-id", shutdown)?;
17//! // drive with a JMAP-aware watch loop that handles the Event yield.
18//! ```
19
20use alloc::{
21    collections::{BTreeMap, BTreeSet, VecDeque},
22    string::String,
23    sync::Arc,
24    vec::Vec,
25};
26use core::{
27    mem,
28    sync::atomic::{AtomicBool, Ordering},
29};
30
31use io_jmap::{
32    coroutine::{JmapCoroutine, JmapCoroutineState, JmapYield},
33    rfc8620::{
34        JmapSession,
35        changes::JmapChangesOutput,
36        event_source::{
37            JmapCloseAfter, JmapStateChange,
38            subscribe::{JmapEventSource, JmapEventSourceError, JmapEventSourceYield},
39        },
40    },
41    rfc8621::email::{
42        JmapEmail, JmapEmailProperty,
43        changes::{JmapEmailChanges, JmapEmailChangesError, JmapEmailChangesOptions},
44        get::{JmapEmailGet, JmapEmailGetError, JmapEmailGetOptions},
45    },
46};
47use log::trace;
48use secrecy::SecretString;
49use thiserror::Error;
50
51use crate::{
52    envelope::event::WatchEvent,
53    flag::types::Flag,
54    jmap::convert::{account_id_of, envelope_from, envelope_properties},
55};
56
57/// JMAP type tag we subscribe to and diff against.
58const EMAIL_TYPE: &str = "Email";
59/// Server-side ping cadence (seconds) for the SSE channel.
60const PING_SECONDS: u64 = 30;
61
62/// Errors produced by [`JmapWatchMailbox`].
63#[derive(Debug, Error)]
64pub enum JmapWatchMailboxError {
65    #[error(transparent)]
66    EventSource(#[from] JmapEventSourceError),
67    #[error(transparent)]
68    EmailChanges(#[from] JmapEmailChangesError),
69    #[error(transparent)]
70    EmailGet(#[from] JmapEmailGetError),
71}
72
73/// Yield mixing socket I/O requests and pre-diffed [`WatchEvent`]s.
74#[derive(Debug)]
75pub enum JmapWatchMailboxYield {
76    /// Read more bytes and feed them via `bytes` on the next resume.
77    WantsRead,
78    /// Write these bytes; the next resume usually takes `bytes: None`.
79    WantsWrite(Vec<u8>),
80    /// One pre-diffed delta from the in-memory shadow.
81    Event(WatchEvent),
82}
83
84/// I/O-free coroutine watching a single JMAP mailbox over one
85/// HTTP/1.1 connection.
86pub struct JmapWatchMailbox {
87    state: State,
88    mailbox_id: String,
89    session: JmapSession,
90    http_auth: SecretString,
91    account_id: String,
92    shutdown: Arc<AtomicBool>,
93    /// email_id to keyword bag; mirrors the server view of the mailbox.
94    shadow: BTreeMap<String, BTreeSet<String>>,
95    /// Latest Email-type state; `sinceState` on the next Email/changes.
96    email_state: Option<String>,
97    /// FIFO of events drained one per resume between cycles.
98    pending: VecDeque<WatchEvent>,
99    /// True until the bootstrap cycle has populated the shadow.
100    suppress_events: bool,
101}
102
103impl JmapWatchMailbox {
104    pub fn new(
105        session: &JmapSession,
106        http_auth: &SecretString,
107        mailbox: &str,
108        shutdown: Arc<AtomicBool>,
109    ) -> Result<Self, JmapWatchMailboxError> {
110        trace!("prepare JMAP mailbox watch");
111        let es = JmapEventSource::new(
112            session,
113            http_auth,
114            &[EMAIL_TYPE],
115            PING_SECONDS,
116            JmapCloseAfter::State,
117            shutdown.clone(),
118        )?;
119        Ok(Self {
120            state: State::Subscribing {
121                es,
122                latest_change: None,
123            },
124            mailbox_id: mailbox.into(),
125            session: session.clone(),
126            http_auth: http_auth.clone(),
127            account_id: account_id_of(session),
128            shutdown,
129            shadow: BTreeMap::new(),
130            email_state: None,
131            pending: VecDeque::new(),
132            suppress_events: true,
133        })
134    }
135
136    /// Fresh [`State::Subscribing`] for the next SSE round.
137    fn fresh_subscription_state(&self) -> Result<State, JmapWatchMailboxError> {
138        let es = JmapEventSource::new(
139            &self.session,
140            &self.http_auth,
141            &[EMAIL_TYPE],
142            PING_SECONDS,
143            JmapCloseAfter::State,
144            self.shutdown.clone(),
145        )?;
146        Ok(State::Subscribing {
147            es,
148            latest_change: None,
149        })
150    }
151
152    /// Inspects the trailing [`JmapStateChange`] and picks the next
153    /// State: Email/changes, KeepAlive, or fresh subscription.
154    fn handle_cycle_end(
155        &mut self,
156        change: Option<JmapStateChange>,
157    ) -> Result<State, JmapWatchMailboxError> {
158        let observed_state = change
159            .as_ref()
160            .and_then(|c| c.changed.get(&self.account_id))
161            .and_then(|ts| ts.get(EMAIL_TYPE))
162            .cloned();
163
164        let needs_diff = match (&observed_state, &self.email_state) {
165            (Some(observed), Some(known)) => observed != known,
166            // NOTE: bootstrap or first observation: always sync.
167            (_, None) => true,
168            // NOTE: no Email state for our account: nothing to do.
169            (None, _) => false,
170        };
171
172        if needs_diff {
173            let since = self.email_state.clone().unwrap_or_default();
174            let changes = JmapEmailChanges::new(
175                &self.session,
176                &self.http_auth,
177                since,
178                JmapEmailChangesOptions::default(),
179            )?;
180            return Ok(State::FetchingChanges(changes));
181        }
182
183        if !self.suppress_events && change.is_some() {
184            self.pending.push_back(WatchEvent::KeepAlive);
185        }
186        Ok(State::Emitting)
187    }
188
189    /// Email/get for the union of created+updated ids; carries the
190    /// destroyed list for later diffing.
191    fn dispatch_get(&self, ok: JmapChangesOutput) -> Result<State, JmapWatchMailboxError> {
192        let JmapChangesOutput {
193            new_state,
194            has_more_changes,
195            mut created,
196            updated,
197            destroyed,
198            ..
199        } = ok;
200
201        if has_more_changes {
202            trace!("JMAP Email/changes truncated; next subscription cycle will catch up");
203        }
204
205        created.extend(updated);
206        let mut properties = envelope_properties();
207        properties.push(JmapEmailProperty::MailboxIds);
208
209        let opts = JmapEmailGetOptions {
210            properties: Some(properties),
211            ..Default::default()
212        };
213        let get = JmapEmailGet::new(&self.session, &self.http_auth, created, opts)?;
214
215        Ok(State::FetchingEmails {
216            get,
217            destroyed,
218            new_state,
219        })
220    }
221
222    /// Folds emails + destroyed ids into the shadow, queueing one
223    /// [`WatchEvent`] per delta unless the bootstrap cycle is running.
224    fn apply_diff(&mut self, emails: Vec<JmapEmail>, destroyed: Vec<String>) {
225        for email in emails {
226            let Some(id) = email.id.clone() else {
227                continue;
228            };
229            let in_mailbox = email
230                .mailbox_ids
231                .as_ref()
232                .is_some_and(|map| map.get(&self.mailbox_id).copied().unwrap_or(false));
233            let new_keywords: BTreeSet<String> = email
234                .keywords
235                .clone()
236                .unwrap_or_default()
237                .into_iter()
238                .filter_map(|(k, v)| if v { Some(k) } else { None })
239                .collect();
240            let was_in_shadow = self.shadow.contains_key(&id);
241
242            if in_mailbox {
243                if was_in_shadow {
244                    let old = self.shadow.get(&id).cloned().unwrap_or_default();
245                    let added: BTreeSet<String> = new_keywords.difference(&old).cloned().collect();
246                    let removed: BTreeSet<String> =
247                        old.difference(&new_keywords).cloned().collect();
248                    if !added.is_empty() && !self.suppress_events {
249                        self.pending.push_back(WatchEvent::FlagsAdded {
250                            mailbox: self.mailbox_id.clone(),
251                            id: id.clone(),
252                            flags: added.into_iter().map(Flag::from_raw).collect(),
253                        });
254                    }
255                    if !removed.is_empty() && !self.suppress_events {
256                        self.pending.push_back(WatchEvent::FlagsRemoved {
257                            mailbox: self.mailbox_id.clone(),
258                            id: id.clone(),
259                            flags: removed.into_iter().map(Flag::from_raw).collect(),
260                        });
261                    }
262                    self.shadow.insert(id, new_keywords);
263                } else {
264                    let envelope = envelope_from(email);
265                    if !self.suppress_events {
266                        self.pending.push_back(WatchEvent::EnvelopeAdded {
267                            mailbox: self.mailbox_id.clone(),
268                            envelope: envelope.clone(),
269                        });
270                    }
271                    self.shadow.insert(envelope.id, new_keywords);
272                }
273            } else if was_in_shadow {
274                if !self.suppress_events {
275                    self.pending.push_back(WatchEvent::EnvelopeRemoved {
276                        mailbox: self.mailbox_id.clone(),
277                        id: id.clone(),
278                    });
279                }
280                self.shadow.remove(&id);
281            }
282        }
283
284        for id in destroyed {
285            if self.shadow.remove(&id).is_some() && !self.suppress_events {
286                self.pending.push_back(WatchEvent::EnvelopeRemoved {
287                    mailbox: self.mailbox_id.clone(),
288                    id,
289                });
290            }
291        }
292    }
293}
294
295impl JmapCoroutine for JmapWatchMailbox {
296    type Yield = JmapWatchMailboxYield;
297    type Return = Result<(), JmapWatchMailboxError>;
298
299    fn resume(&mut self, bytes: Option<&[u8]>) -> JmapCoroutineState<Self::Yield, Self::Return> {
300        if self.shutdown.load(Ordering::SeqCst) {
301            self.state = State::Done;
302            return JmapCoroutineState::Complete(Ok(()));
303        }
304
305        let mut bytes = bytes;
306        loop {
307            match mem::replace(&mut self.state, State::Done) {
308                State::Subscribing {
309                    mut es,
310                    mut latest_change,
311                } => match es.resume(bytes.take()) {
312                    JmapCoroutineState::Yielded(JmapEventSourceYield::WantsRead) => {
313                        self.state = State::Subscribing { es, latest_change };
314                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsRead);
315                    }
316                    JmapCoroutineState::Yielded(JmapEventSourceYield::WantsWrite(out)) => {
317                        self.state = State::Subscribing { es, latest_change };
318                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsWrite(out));
319                    }
320                    JmapCoroutineState::Yielded(JmapEventSourceYield::Frame(change)) => {
321                        latest_change = Some(change);
322                        self.state = State::Subscribing { es, latest_change };
323                    }
324                    JmapCoroutineState::Complete(Ok(())) => {
325                        self.state = match self.handle_cycle_end(latest_change) {
326                            Ok(s) => s,
327                            Err(err) => return JmapCoroutineState::Complete(Err(err)),
328                        };
329                    }
330                    JmapCoroutineState::Complete(Err(err)) => {
331                        return JmapCoroutineState::Complete(Err(err.into()));
332                    }
333                },
334                State::FetchingChanges(mut changes) => match changes.resume(bytes.take()) {
335                    JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
336                        self.state = State::FetchingChanges(changes);
337                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsRead);
338                    }
339                    JmapCoroutineState::Yielded(JmapYield::WantsWrite(out)) => {
340                        self.state = State::FetchingChanges(changes);
341                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsWrite(out));
342                    }
343                    JmapCoroutineState::Complete(Err(err)) => {
344                        return JmapCoroutineState::Complete(Err(err.into()));
345                    }
346                    JmapCoroutineState::Complete(Ok(ok)) => {
347                        self.state = match self.dispatch_get(ok) {
348                            Ok(s) => s,
349                            Err(err) => return JmapCoroutineState::Complete(Err(err)),
350                        };
351                    }
352                },
353                State::FetchingEmails {
354                    mut get,
355                    destroyed,
356                    new_state,
357                } => match get.resume(bytes.take()) {
358                    JmapCoroutineState::Yielded(JmapYield::WantsRead) => {
359                        self.state = State::FetchingEmails {
360                            get,
361                            destroyed,
362                            new_state,
363                        };
364                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsRead);
365                    }
366                    JmapCoroutineState::Yielded(JmapYield::WantsWrite(out)) => {
367                        self.state = State::FetchingEmails {
368                            get,
369                            destroyed,
370                            new_state,
371                        };
372                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::WantsWrite(out));
373                    }
374                    JmapCoroutineState::Complete(Err(err)) => {
375                        return JmapCoroutineState::Complete(Err(err.into()));
376                    }
377                    JmapCoroutineState::Complete(Ok(ok)) => {
378                        self.apply_diff(ok.emails, destroyed);
379                        self.email_state = Some(new_state);
380                        self.suppress_events = false;
381                        self.state = State::Emitting;
382                    }
383                },
384                State::Emitting => {
385                    if let Some(evt) = self.pending.pop_front() {
386                        self.state = State::Emitting;
387                        return JmapCoroutineState::Yielded(JmapWatchMailboxYield::Event(evt));
388                    }
389                    self.state = match self.fresh_subscription_state() {
390                        Ok(s) => s,
391                        Err(err) => return JmapCoroutineState::Complete(Err(err)),
392                    };
393                }
394                State::Done => return JmapCoroutineState::Complete(Ok(())),
395            }
396        }
397    }
398}
399
400/// Internal progression of [`JmapWatchMailbox`].
401enum State {
402    Subscribing {
403        es: JmapEventSource,
404        latest_change: Option<JmapStateChange>,
405    },
406    FetchingChanges(JmapEmailChanges),
407    FetchingEmails {
408        get: JmapEmailGet,
409        destroyed: Vec<String>,
410        new_state: String,
411    },
412    Emitting,
413    Done,
414}