Skip to main content

daaki_imap/connection/
idle.rs

1//! IDLE command implementation (RFC 2177, RFC 5465).
2//!
3//! IDLE is implemented on top of the driver task's typed event queue.
4//! The driver enters IDLE-reading mode where it reads wire responses and
5//! publishes them via [`DriverEventSink`](super::driver::event_sink::DriverEventSink).
6//! The handle-side `idle()` loops over events from
7//! [`next_event`](super::ImapConnection::next_event), filtering
8//! transparent protocol events (capability changes, server keepalive
9//! responses), and returns the first meaningful event as an
10//! [`IdleEvent`](super::IdleEvent). It then signals DONE to end the
11//! IDLE session.
12//!
13//! # RFC references
14//! - RFC 2177 Sections 2–4 (IDLE command, continuation, DONE)
15//! - RFC 9051 Section 6.3.13 (IDLE in `IMAP4rev2`)
16//! - RFC 5465 Sections 3–5.8 (NOTIFY event delivery during IDLE,
17//!   NOTIFICATIONOVERFLOW, NOTIFY NONE)
18
19use std::time::Duration;
20
21use tokio::sync::oneshot;
22use tokio_util::sync::CancellationToken;
23use tracing::trace;
24
25use super::driver::{DriverCommand, IdleTermination};
26use super::typed_event::TypedEvent;
27use super::{IdleEvent, ImapConnection};
28use crate::error::Error;
29use crate::types::response::{UntaggedResponse, UntaggedStatus};
30
31impl ImapConnection {
32    /// Enter IDLE mode and wait for the first server event (RFC 2177).
33    ///
34    /// Sends the IDLE command to the driver task, then waits for
35    /// the first event, a timeout, or cancellation. On any of these,
36    /// sends DONE to end the IDLE session and returns the result.
37    ///
38    /// # Cancellation safety
39    ///
40    /// Dropping the future returned by this method is safe — the driver
41    /// task owns the stream and will eventually detect that the handle
42    /// is gone (channel close). However, if the IDLE command was already
43    /// sent on the wire, the connection may be left in an inconsistent
44    /// state until the driver task exits. Prefer using the
45    /// `cancel` token instead of dropping the future.
46    ///
47    /// # Filtered events
48    ///
49    /// Some server responses are handled transparently and do not
50    /// interrupt the IDLE wait:
51    ///
52    /// - **Capability changes** (`* OK [CAPABILITY ...]`) — already
53    ///   applied by [`ProtocolState::apply_side_effects`]; observe via
54    ///   `state_rx` (RFC 3501 Section 7.2.1).
55    /// - **Informational keepalives** (`* OK text` with no response
56    ///   code) — purely informational per RFC 3501 Section 7.1; servers
57    ///   like Dovecot send these periodically to keep the TCP connection
58    ///   alive.
59    ///
60    /// # Arguments
61    ///
62    /// * `timeout` — maximum time to wait for an event.
63    /// * `cancel` — cancellation token; firing this exits IDLE early.
64    ///
65    /// # Errors
66    ///
67    /// Returns [`Error::DriverGone`] if the driver task has exited,
68    /// or any I/O or protocol error from the IDLE session.
69    pub async fn idle(
70        &self,
71        timeout: Duration,
72        cancel: CancellationToken,
73    ) -> Result<IdleEvent, Error> {
74        // Submit IDLE to the driver task.
75        let (done_tx, done_rx) = oneshot::channel();
76        let (result_tx, result_rx) = oneshot::channel();
77        let dcmd = DriverCommand::Idle { done_rx, result_tx };
78        if self.cmd_tx.send(dcmd).await.is_err() {
79            return Err(self.observe_driver_panic().await);
80        }
81
82        // The driver is now in IDLE mode: it has sent "tag IDLE\r\n",
83        // received the `+` continuation, and is reading events from
84        // the wire. Events flow through the event_sink into our
85        // events_rx channel.
86        //
87        // We select on three signals:
88        // 1. cancel — user requested cancellation
89        // 2. next_event — an event arrived from the server
90        //    (includes timeout — next_event returns None on timeout)
91        // 3. result_rx — the driver exited IDLE on its own
92        //    (server-terminated or error)
93
94        let deadline = tokio::time::Instant::now() + timeout;
95        let mut result_rx = result_rx;
96
97        // Loop until we get a meaningful event, timeout, or
98        // cancellation. Some events (e.g., CapabilityChange) are
99        // handled transparently by the state machine and should not
100        // interrupt IDLE — typed_event_to_idle_event returns None
101        // for those, and we continue waiting.
102        let idle_event: IdleEvent = loop {
103            let remaining = deadline.saturating_duration_since(tokio::time::Instant::now());
104            if remaining.is_zero() {
105                break IdleEvent::Timeout;
106            }
107
108            tokio::select! {
109                biased;
110                // P1: Cancellation always wins.
111                () = cancel.cancelled() => break IdleEvent::Cancelled,
112                // P2: Event from the server (or timeout).
113                maybe_ev = self.next_event(remaining) => {
114                    match maybe_ev {
115                        Ok(Some(ev)) => {
116                            if let Some(e) = typed_event_to_idle_event(ev) {
117                                break e;
118                            }
119                        }
120                        Ok(None) => break IdleEvent::Timeout,
121                        Err(Error::DriverGone) => {
122                            // Driver exited — don't try to send DONE.
123                            return Err(Error::DriverGone);
124                        }
125                        Err(e) => return Err(e),
126                    }
127                }
128                // P3: Driver exited IDLE on its own (server-terminated
129                // or error). Lower priority than events so that events
130                // emitted before the tagged OK are delivered first.
131                idle_result = &mut result_rx => {
132                    return match idle_result {
133                        Ok(Ok(IdleTermination::ServerTerminated)) => {
134                            Ok(IdleEvent::ServerTerminated)
135                        }
136                        Ok(Ok(IdleTermination::ClientDone)) => {
137                            // Shouldn't happen — we haven't sent DONE.
138                            // Treat as server-terminated.
139                            Ok(IdleEvent::ServerTerminated)
140                        }
141                        Ok(Err(e)) => Err(e),
142                        Err(_) => Err(self.observe_driver_panic().await),
143                    };
144                }
145            }
146        };
147
148        // Send DONE to the driver and wait for it to finish IDLE.
149        let _ = done_tx.send(());
150        trace!("idle handle: sent DONE signal");
151
152        // Wait for the driver to send DONE on the wire, drain
153        // remaining responses, and confirm the tagged OK.
154        match result_rx.await {
155            Ok(Ok(_)) => {}
156            Ok(Err(e)) => return Err(e),
157            Err(_) => return Err(self.observe_driver_panic().await),
158        }
159
160        Ok(idle_event)
161    }
162}
163
164/// Convert a [`TypedEvent`] into an [`IdleEvent`], if meaningful.
165///
166/// Maps the internal event representation to the public IDLE-specific
167/// event type. Returns `None` for events that are handled transparently
168/// by the protocol state machine and should not interrupt IDLE
169/// (e.g., capability changes — already applied by `apply_side_effects`,
170/// observable via `state_rx`).
171///
172/// Handles all `TypedEvent` variants, extracting IDLE-relevant data
173/// from `Extension` variants where needed (e.g., `MailboxStatus`,
174/// `Metadata`, `Search`/`Esearch`).
175fn typed_event_to_idle_event(ev: TypedEvent) -> Option<IdleEvent> {
176    match ev {
177        TypedEvent::Exists(n) => Some(IdleEvent::Exists(n)),
178        TypedEvent::Recent(n) => Some(IdleEvent::Recent(n)),
179        TypedEvent::Expunge(n) => Some(IdleEvent::Expunge(n)),
180        TypedEvent::FetchUpdate(f) => Some(IdleEvent::Fetch(f)),
181        TypedEvent::Alert(text) => Some(IdleEvent::Alert(text)),
182        TypedEvent::MailboxEvent(info) => Some(IdleEvent::MailboxEvent(info)),
183        TypedEvent::Vanished { earlier, uids } => Some(IdleEvent::Vanished { earlier, uids }),
184        TypedEvent::NotificationOverflow { code, text } => Some(IdleEvent::NotificationOverflow {
185            code_text: code,
186            resp_text: text,
187        }),
188        TypedEvent::CapabilityChange(_) => {
189            // Capability changes during IDLE are handled transparently
190            // by apply_side_effects — the protocol state is already
191            // updated and the caller can observe it via state_rx.
192            // Surfacing this as an IdleEvent would prematurely end
193            // the IDLE wait (RFC 2177 Section 3 — the client should
194            // remain in IDLE until a meaningful mailbox event, timeout,
195            // or cancellation occurs).
196            trace!("idle: suppressing CapabilityChange (handled by state machine)");
197            None
198        }
199        TypedEvent::Bye { code, text } => Some(IdleEvent::Bye { code, text }),
200        TypedEvent::QueueOverflow {
201            dropped_count,
202            since: _,
203        } => Some(IdleEvent::Alert(format!(
204            "event queue overflow: {dropped_count} events dropped"
205        ))),
206        TypedEvent::MetadataChange {} | TypedEvent::ServerMetadataChange {} => {
207            // Metadata changes during IDLE — no detailed info in
208            // the TypedEvent variant. Map to a generic MetadataChange.
209            // The caller can re-query metadata if needed.
210            Some(IdleEvent::ExtensionEvent(
211                "METADATA change during IDLE".into(),
212            ))
213        }
214        TypedEvent::Extension(resp) => untagged_to_idle_event(*resp),
215    }
216}
217
218/// Convert a raw [`UntaggedResponse`] (wrapped in `TypedEvent::Extension`)
219/// into an [`IdleEvent`], if meaningful.
220///
221/// Returns `None` for purely informational `* OK` keepalives (no response
222/// code) — these are server-generated heartbeats that should not interrupt
223/// IDLE (RFC 3501 Section 7.1).
224///
225/// Handles response types that `TypedEvent::From<UntaggedResponse>`
226/// routes to `Extension` — notably `MailboxStatus`, `Metadata`,
227/// `Search`, and `Esearch`.
228fn untagged_to_idle_event(resp: UntaggedResponse) -> Option<IdleEvent> {
229    match resp {
230        UntaggedResponse::MailboxStatus { mailbox, items } => {
231            Some(IdleEvent::MailboxStatus { mailbox, items })
232        }
233        UntaggedResponse::Metadata { mailbox, entries } => {
234            Some(IdleEvent::MetadataChange { mailbox, entries })
235        }
236        UntaggedResponse::Search { uids, mod_seq: _ } => {
237            // Legacy `* SEARCH` during IDLE — wrap in
238            // EsearchResponse for the SearchUpdate variant.
239            Some(IdleEvent::SearchUpdate(Box::new(
240                crate::types::EsearchResponse {
241                    tag: None,
242                    uid: false,
243                    all: uids
244                        .into_iter()
245                        .map(crate::types::UidRange::single)
246                        .collect(),
247                    min: None,
248                    max: None,
249                    count: None,
250                    mod_seq: None,
251                },
252            )))
253        }
254        UntaggedResponse::Esearch(e) => Some(IdleEvent::SearchUpdate(Box::new(e))),
255        UntaggedResponse::Status {
256            status,
257            code: Some(code),
258            text,
259        } => Some(IdleEvent::StatusUpdate { status, code, text }),
260        // RFC 3501 Section 7.1: `* OK text` with no response code is
261        // purely informational. Servers like Dovecot send these as
262        // periodic keepalives (e.g., `* OK Still here`) to prevent
263        // TCP timeouts. They carry no actionable data and should not
264        // interrupt the IDLE wait.
265        UntaggedResponse::Status {
266            status: UntaggedStatus::Ok,
267            code: None,
268            text,
269        } => {
270            trace!("idle: suppressing informational OK keepalive: {text:?}");
271            None
272        }
273        // Everything else: wrap as extension event.
274        other => Some(IdleEvent::ExtensionEvent(format!("{other:?}"))),
275    }
276}
277
278#[cfg(test)]
279#[path = "idle_tests.rs"]
280mod tests;