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;