Skip to main content

daaki_imap/connection/
mod.rs

1//! IMAP client connection.
2//!
3//! `ImapConnection` is a plain struct (no typestate generics). Callers manage
4//! session lifecycle themselves.
5//!
6//! Connection and authentication are defined in RFC 3501 Sections 6.1-6.2 /
7//! RFC 9051 Sections 6.1-6.2.
8
9use std::sync::Arc;
10use std::time::Duration;
11
12use bytes::BytesMut;
13use flate2::{Compress, Decompress, FlushCompress, FlushDecompress, Status};
14use tokio::io::{AsyncReadExt, AsyncWriteExt};
15use tokio::net::TcpStream;
16use tokio_rustls::client::TlsStream;
17use tokio_rustls::TlsConnector;
18
19use tracing::{debug, warn};
20
21use crate::codec::encode::{
22    encode_multi_append_header_with_literal8, encode_quoted_or_literal,
23    encode_quoted_or_literal_utf8, LiteralMode,
24};
25use crate::error::Error;
26use crate::types::{
27    format_fetch_attrs, AclEntry, AppendMessage, Capability, Command, CopyResult, EsearchResponse,
28    ExpungeResult, FetchAttr, FetchResponse, Flag, ListRightsResponse, MailboxAttribute,
29    MailboxFilter, MailboxInfo, MailboxName, MetadataEntry, MetadataResult, MoveResult,
30    NamespaceResponse, NotifyEvent, NotifySetParams, QresyncParams, QuotaResource,
31    QuotaRootResponse, Response, ResponseCode, SelectOptions, SelectedMailbox, SequenceSet,
32    StatusItem, StatusResult, StoreOperation, StoreResult, TaggedResponse, ThreadNode, UidRange,
33    UntaggedResponse, UntaggedStatus,
34};
35
36mod append;
37mod auth;
38pub(super) mod dispatch;
39pub(super) mod driver;
40mod extensions;
41mod helpers;
42mod idle;
43mod lifecycle;
44mod mailbox;
45pub(super) mod pipeline;
46mod seq_ops;
47mod sort_thread;
48pub(super) mod state;
49mod tag;
50/// Typed event enum for asynchronous server notifications.
51///
52/// See [`TypedEvent`](typed_event::TypedEvent) for the event variants
53/// observable via [`drain_events`](ImapConnection::drain_events) and
54/// [`next_event`](ImapConnection::next_event).
55pub mod typed_event;
56mod uid_ops;
57pub(super) mod wire;
58
59#[cfg(test)]
60#[path = "tests.rs"]
61mod tests;
62
63pub use daaki_message::TlsMode;
64
65/// TCP keepalive configuration for the underlying socket.
66#[non_exhaustive]
67#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
68#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
69pub struct TcpKeepalive {
70    /// Time before the first keepalive probe.
71    pub time: Duration,
72    /// Interval between subsequent probes.
73    pub interval: Duration,
74}
75
76impl TcpKeepalive {
77    /// Create a TCP keepalive configuration with the given time and interval.
78    pub fn new(time: Duration, interval: Duration) -> Self {
79        Self { time, interval }
80    }
81}
82
83/// IMAP session state (RFC 3501 Section 3 / RFC 9051 Section 3).
84///
85/// Tracks the current protocol state of the connection. State transitions
86/// are managed automatically by `ImapConnection` methods.
87#[non_exhaustive]
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
89#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
90pub enum SessionState {
91    /// Not Authenticated — client must authenticate (RFC 3501 Section 3.1).
92    NotAuthenticated,
93    /// Authenticated — client may select a mailbox (RFC 3501 Section 3.2).
94    Authenticated,
95    /// Selected — a mailbox is open (RFC 3501 Section 3.3).
96    Selected,
97    /// Logout — connection is being closed (RFC 3501 Section 3.4).
98    Logout,
99}
100
101/// Event received during an IDLE session (RFC 2177).
102#[non_exhaustive]
103#[derive(Debug, Clone, PartialEq, Eq, Hash)]
104#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
105pub enum IdleEvent {
106    /// New message(s) arrived — `* <n> EXISTS` (RFC 3501 Section 7.3.1).
107    Exists(u32),
108    /// Message expunged — `* <n> EXPUNGE` (RFC 3501 Section 7.4.1).
109    Expunge(u32),
110    /// Messages vanished — `* VANISHED [EARLIER] uid-set` (RFC 7162 Section 3.2.10.2).
111    ///
112    /// After `ENABLE QRESYNC`, servers send VANISHED instead of EXPUNGE.
113    Vanished {
114        /// `true` if this was a `VANISHED (EARLIER)` response (initial sync).
115        earlier: bool,
116        /// UIDs of vanished messages.
117        uids: Vec<UidRange>,
118    },
119    /// Message data changed — `* n FETCH ...` (RFC 3501 Section 7.4.2, RFC 2177 Section 3).
120    ///
121    /// During IDLE, the server may send unsolicited FETCH responses when message
122    /// attributes change (e.g., flags updated by another session). The full
123    /// `FetchResponse` is preserved so callers can inspect the sequence number,
124    /// UID, flags, and any other returned data items.
125    Fetch(Box<crate::types::FetchResponse>),
126    /// Recent message count changed — `* <n> RECENT` (RFC 3501 Section 7.3.2).
127    ///
128    /// RFC 2177 allows the server to send mailbox size messages during IDLE;
129    /// `* n RECENT` is one such message.
130    Recent(u32),
131    /// Server sent an ALERT that MUST be presented to the user
132    /// (RFC 3501 Section 7.1).
133    ///
134    /// RFC 3501 Section 7.1 mandates: "The human-readable text contains a
135    /// special alert that MUST be presented to the user in a fashion that
136    /// calls the user's attention to the message." RFC 2177 (IDLE) does not
137    /// exempt this requirement, so alerts received during IDLE are surfaced
138    /// immediately rather than buffered.
139    Alert(String),
140    /// The idle timed out (caller-supplied timeout elapsed).
141    Timeout,
142    /// The idle was cancelled via the `CancellationToken`.
143    Cancelled,
144    /// Mailbox created, deleted, renamed, or access changed — `* LIST ...`
145    /// (RFC 5465 Sections 5.4–5.5).
146    ///
147    /// When NOTIFY is active with `MailboxName` (§5.4) or
148    /// `SubscriptionChange` (§5.5) events, the server delivers
149    /// mailbox-level notifications as LIST responses during IDLE.
150    MailboxEvent(MailboxInfo),
151    /// Non-selected mailbox status changed — `* STATUS "mailbox" (...)`
152    /// (RFC 5465 Section 4, Sections 5.1–5.3).
153    ///
154    /// When NOTIFY is active with message events (§5.1–5.3) on
155    /// non-selected mailboxes, the server delivers status changes (new
156    /// messages, expunges, flag changes) as STATUS responses during IDLE.
157    /// The initial snapshot is delivered per the STATUS indicator (§4).
158    MailboxStatus {
159        /// The mailbox whose status changed.
160        mailbox: MailboxName,
161        /// The status items that changed.
162        items: Vec<StatusItem>,
163    },
164    /// Mailbox or server metadata changed — `* METADATA "mailbox" (...)`
165    /// (RFC 5465 Sections 5.6–5.7).
166    ///
167    /// When NOTIFY is active with `MailboxMetadataChange` (§5.6) or
168    /// `ServerMetadataChange` (§5.7) events, the server delivers metadata
169    /// notifications as METADATA responses during IDLE.
170    MetadataChange {
171        /// The mailbox whose metadata changed (empty string for server-level).
172        mailbox: MailboxName,
173        /// The metadata entries that changed.
174        entries: Vec<MetadataEntry>,
175    },
176    /// Search context update — `* ESEARCH ...` or `* SEARCH ...`
177    /// (RFC 5267 Sections 2.4 / RFC 5465 Sections 5.1-5.3).
178    ///
179    /// When a search context is active (RFC 5267) or NOTIFY triggers
180    /// search-related notifications, the server may deliver ESEARCH
181    /// updates during IDLE.
182    ///
183    /// **Note on legacy `* SEARCH` conversion:** When the server sends a
184    /// legacy `* SEARCH n1 n2 ...` update (instead of ESEARCH), the numbers
185    /// are wrapped in an `EsearchResponse` with `uid: false`. However, IMAP
186    /// uses the same wire form for both sequence-number and UID results —
187    /// the `uid` field may be inaccurate for legacy SEARCH. Callers should
188    /// use the `tag` field (if present) to correlate with the original search
189    /// context and determine the number semantics.
190    SearchUpdate(Box<crate::types::EsearchResponse>),
191    /// Extension-defined untagged response not recognized by the parser
192    /// (RFC 9051 Section 2.2.2).
193    ///
194    /// When `NotifyEvent::Other(...)` is registered, the server may deliver
195    /// extension-defined notifications using response types this client does
196    /// not implement. The raw response line is preserved so callers can
197    /// parse extension data themselves.
198    ExtensionEvent(String),
199    /// Unsolicited status update with a response code — `* OK [code]` or
200    /// `* NO [code]` (RFC 3501 Section 7.1).
201    ///
202    /// Covers `[PERMANENTFLAGS]`, `[UIDVALIDITY]`, and any other response
203    /// code not handled by a more specific variant. Surfaced during IDLE
204    /// so the caller can react to state changes (e.g., updated writable
205    /// flags, NOTIFY-driven metadata updates).
206    StatusUpdate {
207        /// The original response condition (RFC 3501 Section 7.1).
208        /// OK, NO, and BAD are semantically distinct and must be preserved.
209        status: UntaggedStatus,
210        /// The response code.
211        code: ResponseCode,
212        /// Human-readable text.
213        text: String,
214    },
215    /// The server discarded the NOTIFY registration due to overflow
216    /// (RFC 5465 Section 5.8).
217    ///
218    /// The client MUST behave as if `NOTIFY NONE` was received — the
219    /// registration is gone and no further notifications will be delivered.
220    /// Callers should re-issue `notify_set()` to re-establish monitoring.
221    NotificationOverflow {
222        /// The response-code payload from `[NOTIFICATIONOVERFLOW ...]`
223        /// (RFC 5465 Section 5.8). `None` when the server omits the
224        /// optional argument.
225        code_text: Option<String>,
226        /// Human-readable text from the untagged status line
227        /// (RFC 3501 Section 7.1).
228        resp_text: String,
229    },
230    /// The server sent `* BYE` — the connection is closing
231    /// (RFC 3501 Section 7.1.5).
232    ///
233    /// Unlike [`Alert`], this signals that the server is terminating
234    /// the connection. After receiving this event, further commands
235    /// will fail.
236    Bye {
237        /// Optional response code from the BYE response.
238        code: Option<ResponseCode>,
239        /// Human-readable reason for disconnection.
240        text: String,
241    },
242    /// The server terminated the IDLE session by sending the tagged OK
243    /// response (RFC 2177 Section 3).
244    ///
245    /// Some servers (Exchange, Zimbra) have short IDLE limits and terminate
246    /// IDLE from the server side by sending the tagged OK rather than
247    /// waiting for the client's DONE. When this happens, the client MUST NOT
248    /// send DONE because the IDLE command is already complete.
249    ServerTerminated,
250}
251
252/// Result of a SEARCH or UID SEARCH command.
253///
254/// Contains both the matching sequence numbers/UIDs and the optional
255/// highest mod-sequence value (RFC 7162 Section 3.1.5).
256#[non_exhaustive]
257#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
258#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
259pub struct SearchResult {
260    /// Matching message sequence numbers (SEARCH) or UIDs (UID SEARCH).
261    pub ids: Vec<u32>,
262    /// Highest mod-sequence of matching messages, if MODSEQ was used
263    /// in the search criteria (RFC 7162 Section 3.1.5).
264    pub mod_seq: Option<u64>,
265    /// `true` when ESEARCH UID range expansion was capped at the internal
266    /// safety limit and the returned `ids` do not faithfully represent the
267    /// full server response (RFC 4731 Section 3, RFC 3501 Section 6.4.4).
268    pub truncated: bool,
269}
270
271// ---------------------------------------------------------------------------
272// Stream abstraction
273// ---------------------------------------------------------------------------
274
275/// The inner transport stream — either plain TCP or TLS over TCP.
276///
277/// Used as the underlying I/O transport for both uncompressed and compressed
278/// connections. The `Tls` variant is large due to TLS session state — same
279/// rationale as `ImapStream` for not boxing.
280#[allow(clippy::large_enum_variant)]
281enum InnerStream {
282    Plain(TcpStream),
283    Tls(TlsStream<TcpStream>),
284}
285
286impl InnerStream {
287    async fn read_buf(&mut self, buf: &mut BytesMut) -> std::io::Result<usize> {
288        match self {
289            Self::Plain(s) => s.read_buf(buf).await,
290            Self::Tls(s) => s.read_buf(buf).await,
291        }
292    }
293
294    async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
295        match self {
296            Self::Plain(s) => s.write_all(data).await,
297            Self::Tls(s) => s.write_all(data).await,
298        }
299    }
300
301    async fn flush(&mut self) -> std::io::Result<()> {
302        match self {
303            Self::Plain(s) => s.flush().await,
304            Self::Tls(s) => s.flush().await,
305        }
306    }
307}
308
309/// Compressed stream wrapper implementing COMPRESS=DEFLATE (RFC 4978).
310///
311/// Wraps an `InnerStream` with raw deflate compression/decompression.
312/// Per RFC 4978 Section 3, uses raw deflate (RFC 1951) — not gzip, not zlib —
313/// with `SyncFlush` after each write to ensure the peer can decode immediately.
314struct CompressedStream {
315    /// The underlying TCP or TLS stream.
316    inner: InnerStream,
317    /// Decompressor for inflating data received from the server.
318    decompress: Decompress,
319    /// Compressor for deflating data sent to the server.
320    compress: Compress,
321    /// Buffer holding raw (compressed) bytes read from the network that have
322    /// not yet been inflated.
323    raw_read_buf: BytesMut,
324    /// Scratch buffer used as destination during inflate operations.
325    inflate_buf: Vec<u8>,
326}
327
328/// Initial size of the raw-read buffer for compressed streams.
329const COMPRESSED_RAW_BUF_SIZE: usize = 8192;
330/// Size of the scratch buffer used during inflate.
331const INFLATE_BUF_SIZE: usize = 16384;
332/// Size of the output buffer used during deflate.
333const DEFLATE_BUF_SIZE: usize = 16384;
334
335impl CompressedStream {
336    /// Create a new compressed stream wrapping the given inner stream.
337    ///
338    /// Initialises raw deflate compressor/decompressor per RFC 4978 Section 3.
339    fn new(inner: InnerStream) -> Self {
340        Self {
341            inner,
342            // RFC 4978 Section 3: raw deflate (no zlib/gzip header).
343            decompress: Decompress::new(false),
344            compress: Compress::new(flate2::Compression::default(), false),
345            raw_read_buf: BytesMut::with_capacity(COMPRESSED_RAW_BUF_SIZE),
346            inflate_buf: vec![0u8; INFLATE_BUF_SIZE],
347        }
348    }
349
350    /// Read decompressed data into `buf`.
351    ///
352    /// Reads compressed bytes from the inner stream, then inflates them into
353    /// the caller's buffer. Returns the number of decompressed bytes appended.
354    //
355    // The u64→usize casts on total_in/total_out deltas are safe: each delta
356    // is bounded by the buffer size (at most INFLATE_BUF_SIZE = 16 KiB).
357    #[allow(clippy::cast_possible_truncation)]
358    async fn read_buf(&mut self, buf: &mut BytesMut) -> std::io::Result<usize> {
359        loop {
360            // Try to inflate any data already in the raw buffer.
361            if !self.raw_read_buf.is_empty() {
362                let before_in = self.decompress.total_in();
363                let before_out = self.decompress.total_out();
364
365                let status = self
366                    .decompress
367                    .decompress(
368                        &self.raw_read_buf,
369                        &mut self.inflate_buf,
370                        FlushDecompress::Sync,
371                    )
372                    .map_err(|e| {
373                        std::io::Error::new(
374                            std::io::ErrorKind::InvalidData,
375                            format!("deflate decompression error: {e}"),
376                        )
377                    })?;
378
379                let consumed = (self.decompress.total_in() - before_in) as usize;
380                let produced = (self.decompress.total_out() - before_out) as usize;
381
382                // Advance past consumed compressed bytes.
383                if consumed > 0 {
384                    let _ = self.raw_read_buf.split_to(consumed);
385                }
386
387                if produced > 0 {
388                    buf.extend_from_slice(&self.inflate_buf[..produced]);
389                    return Ok(produced);
390                }
391
392                // If the stream has ended, signal EOF.
393                if status == Status::StreamEnd {
394                    return Ok(0);
395                }
396            }
397
398            // Need more compressed data from the network.
399            let n = self.inner.read_buf(&mut self.raw_read_buf).await?;
400            if n == 0 {
401                return Ok(0); // EOF on underlying stream.
402            }
403        }
404    }
405
406    /// Compress `data` and write it to the inner stream.
407    ///
408    /// Per RFC 4978 Section 3, each IMAP command/response is terminated with
409    /// `SyncFlush` so the peer can decompress without waiting for more data.
410    //
411    // The u64→usize casts on total_in/total_out deltas are safe: each delta
412    // is bounded by the buffer size (at most DEFLATE_BUF_SIZE = 16 KiB).
413    #[allow(clippy::cast_possible_truncation)]
414    async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
415        let mut deflate_buf = vec![0u8; DEFLATE_BUF_SIZE];
416        let mut input_offset = 0;
417
418        // Compress the input data in chunks.
419        while input_offset < data.len() {
420            let before_in = self.compress.total_in();
421            let before_out = self.compress.total_out();
422
423            self.compress
424                .compress(&data[input_offset..], &mut deflate_buf, FlushCompress::None)
425                .map_err(|e| {
426                    std::io::Error::new(
427                        std::io::ErrorKind::InvalidData,
428                        format!("deflate compression error: {e}"),
429                    )
430                })?;
431
432            let consumed = (self.compress.total_in() - before_in) as usize;
433            let produced = (self.compress.total_out() - before_out) as usize;
434
435            input_offset += consumed;
436
437            if produced > 0 {
438                self.inner.write_all(&deflate_buf[..produced]).await?;
439            }
440        }
441
442        // SyncFlush to ensure the server can decode immediately
443        // (RFC 4978 Section 3).
444        //
445        // Issue the Sync flush exactly once, then switch to None for any
446        // remaining overflow. Calling Sync repeatedly would emit a new
447        // sync marker each time (producing output indefinitely).
448        let mut flush = FlushCompress::Sync;
449        loop {
450            let before_out = self.compress.total_out();
451
452            self.compress
453                .compress(&[], &mut deflate_buf, flush)
454                .map_err(|e| {
455                    std::io::Error::new(
456                        std::io::ErrorKind::InvalidData,
457                        format!("deflate sync-flush error: {e}"),
458                    )
459                })?;
460
461            let produced = (self.compress.total_out() - before_out) as usize;
462
463            if produced > 0 {
464                self.inner.write_all(&deflate_buf[..produced]).await?;
465            }
466
467            if produced == 0 {
468                break;
469            }
470
471            // After the initial Sync, switch to None to drain any remaining
472            // buffered output without emitting additional sync markers.
473            flush = FlushCompress::None;
474        }
475
476        Ok(())
477    }
478
479    /// Flush the underlying stream.
480    async fn flush(&mut self) -> std::io::Result<()> {
481        self.inner.flush().await
482    }
483}
484
485/// Wraps either a plain TCP, TLS, or compressed stream.
486///
487/// Delegates `AsyncRead`/`AsyncWrite` via match — no `unsafe` code.
488/// The `Tls` variant is large due to the TLS session state — boxing it
489/// would add indirection on every I/O call, which is not worth it.
490#[allow(clippy::large_enum_variant)]
491enum ImapStream {
492    Plain(TcpStream),
493    Tls(TlsStream<TcpStream>),
494    /// Compressed stream per RFC 4978 (COMPRESS=DEFLATE).
495    Compressed(CompressedStream),
496    /// Sentinel used during in-progress stream upgrades (STARTTLS,
497    /// COMPRESS). All I/O operations return an error immediately.
498    /// If the upgrade fails or the future is cancelled, the stream
499    /// stays `Poisoned` forever and the connection is dead — this is
500    /// the enforcement of I9 (atomic upgrades).
501    /// RFC 3501 §6.2.1 / RFC 4978.
502    Poisoned,
503    /// In-memory transport used by the test harness. Backed by
504    /// [`tokio::io::DuplexStream`] so unit tests do not need to bind a real
505    /// loopback socket — restricted environments (sandboxes, hardened CI
506    /// runners) can disallow `TcpListener::bind("127.0.0.1:0")`. Gated to
507    /// `cfg(test)` so production builds carry zero overhead.
508    #[cfg(test)]
509    Memory(tokio::io::DuplexStream),
510}
511
512impl ImapStream {
513    async fn read_buf(&mut self, buf: &mut BytesMut) -> std::io::Result<usize> {
514        match self {
515            Self::Plain(s) => s.read_buf(buf).await,
516            Self::Tls(s) => s.read_buf(buf).await,
517            Self::Compressed(s) => s.read_buf(buf).await,
518            Self::Poisoned => Err(std::io::Error::other("stream poisoned during upgrade")),
519            #[cfg(test)]
520            Self::Memory(s) => s.read_buf(buf).await,
521        }
522    }
523
524    async fn write_all(&mut self, data: &[u8]) -> std::io::Result<()> {
525        match self {
526            Self::Plain(s) => s.write_all(data).await,
527            Self::Tls(s) => s.write_all(data).await,
528            Self::Compressed(s) => s.write_all(data).await,
529            Self::Poisoned => Err(std::io::Error::other("stream poisoned during upgrade")),
530            #[cfg(test)]
531            Self::Memory(s) => s.write_all(data).await,
532        }
533    }
534
535    async fn flush(&mut self) -> std::io::Result<()> {
536        match self {
537            Self::Plain(s) => s.flush().await,
538            Self::Tls(s) => s.flush().await,
539            Self::Compressed(s) => s.flush().await,
540            Self::Poisoned => Err(std::io::Error::other("stream poisoned during upgrade")),
541            #[cfg(test)]
542            Self::Memory(s) => s.flush().await,
543        }
544    }
545
546    /// Set TCP keepalive on the underlying socket (RFC 1122 Section 4.2.3.6).
547    ///
548    /// Configures the operating system's TCP keepalive probes via
549    /// `setsockopt(2)`. Works on plain TCP, TLS (reaches through to the
550    /// inner `TcpStream`), and compressed streams. Returns an error for
551    /// `Poisoned` (upgrade in progress) and `Memory` (test-only) streams.
552    fn set_keepalive(&self, ka: &TcpKeepalive) -> Result<(), Error> {
553        use socket2::SockRef;
554
555        let sock_ka = socket2::TcpKeepalive::new()
556            .with_time(ka.time)
557            .with_interval(ka.interval);
558
559        let result = match self {
560            Self::Plain(tcp) => SockRef::from(tcp).set_tcp_keepalive(&sock_ka),
561            Self::Tls(tls) => SockRef::from(tls.get_ref().0).set_tcp_keepalive(&sock_ka),
562            Self::Compressed(c) => {
563                let inner_result = match &c.inner {
564                    InnerStream::Plain(tcp) => SockRef::from(tcp).set_tcp_keepalive(&sock_ka),
565                    InnerStream::Tls(tls) => {
566                        SockRef::from(tls.get_ref().0).set_tcp_keepalive(&sock_ka)
567                    }
568                };
569                inner_result
570            }
571            Self::Poisoned => {
572                return Err(Error::Io(std::sync::Arc::new(std::io::Error::other(
573                    "cannot set keepalive: stream is in upgrade transition",
574                ))));
575            }
576            #[cfg(test)]
577            Self::Memory(_) => {
578                return Err(Error::Io(std::sync::Arc::new(std::io::Error::other(
579                    "keepalive not supported on memory streams",
580                ))));
581            }
582        };
583        result.map_err(|e| Error::Io(std::sync::Arc::new(e)))
584    }
585
586    /// Extract the underlying `TcpStream` for STARTTLS upgrade.
587    fn into_tcp(self) -> Option<TcpStream> {
588        match self {
589            Self::Plain(s) => Some(s),
590            Self::Tls(_) | Self::Compressed(_) | Self::Poisoned => Option::None,
591            #[cfg(test)]
592            // Memory streams cannot upgrade to TLS — STARTTLS tests must
593            // use the real transport.
594            Self::Memory(_) => Option::None,
595        }
596    }
597}
598
599// ---------------------------------------------------------------------------
600// ImapConnection
601// ---------------------------------------------------------------------------
602
603/// An IMAP client connection (RFC 3501 Section 2 / RFC 9051 Section 2).
604///
605/// Manages a single TCP (or TLS) connection to an IMAP server. All operations
606/// are async and require a caller-supplied timeout — there are no hardcoded
607/// defaults and no infinite waits.
608///
609/// # Connection state
610///
611/// RFC 3501 Section 3 defines four session states: Not Authenticated,
612/// Authenticated, Selected, and Logout. Each command method validates that
613/// the connection is in an allowed state before sending, returning
614/// [`Error::Protocol`] if not. For example, [`uid_fetch()`](Self::uid_fetch)
615/// requires the Selected state and will fail if called before
616/// [`select()`](Self::select).
617pub struct ImapConnection {
618    /// Channel for submitting commands to the driver task.
619    cmd_tx: tokio::sync::mpsc::Sender<driver::DriverCommand>,
620    /// Watch receiver for observing connection state snapshots.
621    state_rx: tokio::sync::watch::Receiver<driver::ConnectionStateSnapshot>,
622    /// Receiver for asynchronous server events (ALERTs, EXISTS, etc.).
623    /// Wrapped in `Mutex` so event consumption does not require `&mut self`.
624    events_rx: tokio::sync::Mutex<tokio::sync::mpsc::Receiver<typed_event::TypedEvent>>,
625    /// Handle to the driver task. Observed on shutdown or on submit
626    /// failure to surface panics as `Error::DriverPanicked`.
627    /// Wrapped in `Mutex<Option<...>>` so that `observe_driver_panic`
628    /// can take the handle when shutting down, without needing `&mut self`.
629    driver_handle: tokio::sync::Mutex<Option<tokio::task::JoinHandle<()>>>,
630    /// Tag counter for pre-built commands (APPEND / MULTIAPPEND).
631    ///
632    /// Uses prefix `P` to avoid collision with the driver's hex-format
633    /// tags. `AtomicU32` enables `&self` access without interior mutability.
634    prebuilt_tag_counter: std::sync::atomic::AtomicU32,
635    /// Server hostname, retained for STARTTLS upgrade (RFC 3501 §6.2.1).
636    ///
637    /// Needed to construct the `ServerName` for TLS SNI and certificate
638    /// verification when the caller invokes `starttls()`.
639    host: String,
640}
641
642/// Per-type NOTIFY flags (RFC 5465 Sections 5.1–5.8).
643///
644/// Tracks which response types the current NOTIFY registration can produce
645/// so that IDLE event classification and LIST filtering during LIST-family
646/// commands know which types to expect.
647#[derive(Debug, Clone, Copy, Default)]
648pub(crate) struct NotifyFlags {
649    /// `MailboxName` or `SubscriptionChange` events registered
650    /// (RFC 5465 Sections 5.4–5.5 — delivered as LIST).
651    pub(crate) list: bool,
652    /// `MessageNew`/`MessageExpunge` on non-selected mailboxes or STATUS
653    /// indicator (RFC 5465 Sections 4, 5.1–5.2 — delivered as STATUS).
654    pub(crate) status: bool,
655    /// `MailboxMetadataChange` or `ServerMetadataChange` events registered
656    /// (RFC 5465 Sections 5.6–5.7 — delivered as METADATA).
657    pub(crate) metadata: bool,
658}
659
660// Compile-time proof that ImapConnection is Send — required for
661// holding the connection across `.await` points in async tasks.
662const _: fn() = || {
663    fn assert_send<T: Send>() {}
664    assert_send::<ImapConnection>();
665};
666
667/// Wire literal syntax for APPEND message data.
668///
669/// RFC 3501 Section 4.3 / RFC 9051 Section 4.3 define classic `literal`
670/// syntax for `CHAR8` data (no NUL octets). RFC 3516 Section 4.4 extends
671/// APPEND with `literal8` for binary data, and RFC 6855 Section 4 wraps
672/// `literal8` in `UTF8 (...)` when UTF8=ACCEPT is enabled for UTF-8 headers.
673#[derive(Debug, Clone, Copy, PartialEq, Eq)]
674enum AppendLiteralKind {
675    /// Classic `{N}` / `{N+}` APPEND literal for `CHAR8` data.
676    /// RFC 3501 Section 4.3 / RFC 9051 Section 4.3.
677    Literal,
678    /// Binary `~{N}` APPEND literal for data containing NUL octets.
679    /// RFC 3516 Section 4.4.
680    Literal8,
681    /// UTF8 APPEND wrapper using `UTF8 (~{N})`.
682    /// RFC 6855 Section 4.
683    Utf8Literal8,
684}
685
686impl ImapConnection {
687    /// Generate the next tag for a pre-built command (APPEND/MULTIAPPEND).
688    ///
689    /// Uses `P` prefix to avoid collision with the driver's hex-format
690    /// tags (RFC 3501 §2.2.1). Safe to call from `&self` via atomic
691    /// increment.
692    pub(super) fn next_prebuilt_tag(&self) -> String {
693        let n = self
694            .prebuilt_tag_counter
695            .fetch_add(1, std::sync::atomic::Ordering::Relaxed)
696            .wrapping_add(1);
697        format!("P{n:03}")
698    }
699
700    /// Drain all pending events from the typed event queue.
701    ///
702    /// Returns every [`TypedEvent`] that has accumulated since the last
703    /// call to `drain_events` or `next_event`. Non-blocking — returns an
704    /// empty `Vec` when no events are pending.
705    ///
706    /// This is the primary way to observe asynchronous server data
707    /// (ALERTs, EXISTS/EXPUNGE, NOTIFY events, BYE, etc.) outside of an
708    /// active command or IDLE session.
709    pub async fn drain_events(&self) -> Vec<typed_event::TypedEvent> {
710        let mut rx = self.events_rx.lock().await;
711        let mut out = Vec::new();
712        while let Ok(ev) = rx.try_recv() {
713            out.push(ev);
714        }
715        out
716    }
717
718    /// Wait for the next event from the typed event queue, with a
719    /// timeout.
720    ///
721    /// Returns `Ok(Some(event))` when an event arrives, `Ok(None)` when
722    /// `timeout` elapses without an event, or `Err(Error::DriverGone)`
723    /// when the driver task has exited (channel closed).
724    ///
725    /// RFC 3501 §5.3: servers may send untagged data at any time. This
726    /// method surfaces that data as [`TypedEvent`]s, enabling callers to
727    /// react to mailbox state changes, ALERTs, and NOTIFY events.
728    pub async fn next_event(
729        &self,
730        timeout: std::time::Duration,
731    ) -> Result<Option<typed_event::TypedEvent>, crate::error::Error> {
732        let mut rx = self.events_rx.lock().await;
733        match tokio::time::timeout(timeout, rx.recv()).await {
734            Ok(Some(ev)) => Ok(Some(ev)),
735            Ok(None) => Err(crate::error::Error::DriverGone),
736            Err(_) => Ok(None), // timeout
737        }
738    }
739}
740
741impl std::fmt::Debug for ImapConnection {
742    /// Prints connection metadata useful for logging and diagnostics,
743    /// without exposing internal stream state or buffers.
744    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
745        let snapshot = self.state_rx.borrow();
746        f.debug_struct("ImapConnection")
747            .field("state", &snapshot.session_state)
748            .field("capabilities_count", &snapshot.capabilities.len())
749            .field("cmd_tx_closed", &self.cmd_tx.is_closed())
750            .finish_non_exhaustive()
751    }
752}
753
754// ---------------------------------------------------------------------------
755// Helpers
756// ---------------------------------------------------------------------------
757
758/// Build the default TLS config with webpki roots.
759///
760/// Installs the ring `CryptoProvider` if no provider has been set yet.
761fn build_default_tls_config() -> Arc<rustls::ClientConfig> {
762    // Install ring as the crypto provider (no-op if already installed).
763    let _ = rustls::crypto::ring::default_provider().install_default();
764
765    let root_store: rustls::RootCertStore =
766        webpki_roots::TLS_SERVER_ROOTS.iter().cloned().collect();
767    let config = rustls::ClientConfig::builder()
768        .with_root_certificates(root_store)
769        .with_no_client_auth();
770    Arc::new(config)
771}
772
773/// Find the next synchronizing literal boundary (`{digits}\r\n`) in `buf`.
774///
775/// Returns `Some((offset, size))` where `offset` is past the `\r\n` (i.e., the
776/// literal data starts at `buf[offset..]`), and `size` is the literal byte count.
777/// Returns `None` if no literal is found.
778///
779/// Only matches `{digits}\r\n` (synchronizing), NOT `{digits+}\r\n` (LITERAL+).
780fn find_literal_boundary(buf: &[u8]) -> Option<(usize, usize)> {
781    let mut i = 0;
782    while i < buf.len() {
783        if buf[i] == b'{' {
784            let start = i + 1;
785            // Scan for digits
786            let mut j = start;
787            while j < buf.len() && buf[j].is_ascii_digit() {
788                j += 1;
789            }
790            // Must have at least one digit, then `}\r\n`
791            if j > start
792                && j + 2 < buf.len()
793                && buf[j] == b'}'
794                && buf[j + 1] == b'\r'
795                && buf[j + 2] == b'\n'
796            {
797                // Parse the literal size so callers can skip the body.
798                // If the digit sequence is not valid UTF-8 or overflows usize,
799                // skip this candidate and keep scanning — returning a 0-byte
800                // literal would desynchronize the caller (RFC 3501 Section 4.3).
801                let Ok(size_str) = std::str::from_utf8(&buf[start..j]) else {
802                    i += 1;
803                    continue;
804                };
805                let Ok(size) = size_str.parse::<usize>() else {
806                    i += 1;
807                    continue;
808                };
809                // This is a synchronizing literal (no `+` before `}`)
810                return Some((j + 3, size));
811            }
812        }
813        i += 1;
814    }
815    None
816}
817
818/// Patch all synchronizing literal markers in `buf` to non-synchronizing (LITERAL+).
819///
820/// Replaces every `{digits}\r\n` with `{digits+}\r\n` (RFC 7888 Section 4).
821/// Length-aware: after patching a marker, skips the literal body so that
822/// `{digits}\r\n` patterns inside literal data are not modified.
823///
824/// Literal8 markers (`~{digits}\r\n`, RFC 3516) are only converted when the
825/// caller indicates that both BINARY and the relevant literal extension are
826/// active (RFC 7888 Section 6).
827fn patch_literals_to_plus_with_binary(buf: &[u8], allow_literal8: bool) -> BytesMut {
828    let mut result = BytesMut::with_capacity(buf.len() + 16);
829    let mut i = 0;
830    while i < buf.len() {
831        if buf[i] == b'{' {
832            let start = i + 1;
833            let mut j = start;
834            while j < buf.len() && buf[j].is_ascii_digit() {
835                j += 1;
836            }
837            if j > start
838                && j + 2 < buf.len()
839                && buf[j] == b'}'
840                && buf[j + 1] == b'\r'
841                && buf[j + 2] == b'\n'
842            {
843                // Parse the literal size to skip the body.
844                // If the digit sequence overflows usize, treat the `{…}` as
845                // plain text — do not patch it (RFC 3501 Section 4.3).
846                let Ok(size_str) = std::str::from_utf8(&buf[start..j]) else {
847                    result.extend_from_slice(&buf[i..=i]);
848                    i += 1;
849                    continue;
850                };
851                let Ok(size) = size_str.parse::<usize>() else {
852                    result.extend_from_slice(&buf[i..=i]);
853                    i += 1;
854                    continue;
855                };
856
857                // RFC 7888 Section 6 / RFC 3516: literal8 markers (`~{N}\r\n`)
858                // may only use the non-synchronizing form when BINARY is also
859                // advertised alongside LITERAL+.
860                let is_literal8 = i > 0 && buf[i - 1] == b'~';
861
862                // Copy `{digits` then insert `+}\r\n` for non-synchronizing
863                // literals that the server advertised support for.
864                result.extend_from_slice(&buf[i..j]);
865                if !is_literal8 || allow_literal8 {
866                    result.extend_from_slice(b"+}\r\n");
867                } else {
868                    result.extend_from_slice(b"}\r\n");
869                }
870                let body_start = j + 3;
871                // Copy the literal body verbatim (RFC 3501 Section 4.3).
872                // Use checked arithmetic to prevent overflow when `size`
873                // is near `usize::MAX` (e.g., from a crafted command).
874                let body_end = body_start
875                    .checked_add(size)
876                    .map_or(buf.len(), |end| end.min(buf.len()));
877                result.extend_from_slice(&buf[body_start..body_end]);
878                i = body_end;
879                continue;
880            }
881        }
882        result.extend_from_slice(&buf[i..=i]);
883        i += 1;
884    }
885    result
886}
887
888/// Patch synchronizing literals up to 4096 bytes to non-synchronizing (LITERAL-).
889///
890/// Converts `{digits}\r\n` to `{digits+}\r\n` only when `digits` (the
891/// literal octet count) is <= 4096.
892/// Larger literals are left as synchronizing, per RFC 7888 Section 5.
893///
894/// Literal8 markers (`~{digits}\r\n`, RFC 3516) are only converted when the
895/// caller indicates that both BINARY and the relevant literal extension are
896/// active (RFC 7888 Section 6).
897///
898/// Length-aware: after patching (or skipping) a marker, skips the literal
899/// body so that `{digits}\r\n` patterns inside literal data are not modified
900/// (RFC 3501 Section 4.3).
901fn patch_small_literals_to_plus_with_binary(buf: &[u8], allow_literal8: bool) -> BytesMut {
902    /// RFC 7888 Section 5: LITERAL- limit.
903    const LITERAL_MINUS_MAX: usize = 4096;
904
905    let mut result = BytesMut::with_capacity(buf.len() + 16);
906    let mut i = 0;
907    while i < buf.len() {
908        if buf[i] == b'{' {
909            let start = i + 1;
910            let mut j = start;
911            while j < buf.len() && buf[j].is_ascii_digit() {
912                j += 1;
913            }
914            if j > start
915                && j + 2 < buf.len()
916                && buf[j] == b'}'
917                && buf[j + 1] == b'\r'
918                && buf[j + 2] == b'\n'
919            {
920                // Parse the literal size to decide whether to patch and to skip the body.
921                // If the digit sequence is not valid UTF-8 or overflows usize,
922                // treat the `{` as plain text — do not patch it (RFC 3501 Section 4.3).
923                let Ok(size_str) = std::str::from_utf8(&buf[start..j]) else {
924                    result.extend_from_slice(&buf[i..=i]);
925                    i += 1;
926                    continue;
927                };
928                let Ok(size) = size_str.parse::<usize>() else {
929                    result.extend_from_slice(&buf[i..=i]);
930                    i += 1;
931                    continue;
932                };
933
934                // RFC 7888 Section 6 / RFC 3516: literal8 markers (`~{N}\r\n`)
935                // may only use the non-synchronizing form when BINARY is also
936                // advertised alongside the literal extension.
937                let is_literal8 = i > 0 && buf[i - 1] == b'~';
938
939                // Copy `{digits`
940                result.extend_from_slice(&buf[i..j]);
941                if size <= LITERAL_MINUS_MAX && (!is_literal8 || allow_literal8) {
942                    // RFC 7888 Section 5: small literal, upgrade to non-synchronizing.
943                    result.extend_from_slice(b"+}\r\n");
944                } else {
945                    // Large literal or literal8: leave as synchronizing.
946                    result.extend_from_slice(b"}\r\n");
947                }
948                let body_start = j + 3;
949                // Copy the literal body verbatim (RFC 3501 Section 4.3).
950                // Use checked arithmetic to prevent overflow when `size`
951                // is near `usize::MAX` (e.g., from a crafted command).
952                let body_end = body_start
953                    .checked_add(size)
954                    .map_or(buf.len(), |end| end.min(buf.len()));
955                result.extend_from_slice(&buf[body_start..body_end]);
956                i = body_end;
957                continue;
958            }
959        }
960        result.extend_from_slice(&buf[i..=i]);
961        i += 1;
962    }
963    result
964}
965
966/// Filter flags not valid in STORE flag lists (RFC 3501 Section 2.3.2).
967///
968/// `\Recent` is server-only and `\*` (wildcard) is not valid in STORE.
969fn filter_store_flags(flags: &[Flag]) -> Vec<Flag> {
970    flags
971        .iter()
972        .filter(|f| !matches!(f, Flag::Recent | Flag::Wildcard))
973        .cloned()
974        .collect()
975}
976
977/// Expand a slice of [`UidRange`] into individual UIDs.
978///
979/// Used for backward-compatible conversion of ESEARCH ALL uid-set results
980/// into a flat `Vec<u32>` matching the legacy SEARCH response format.
981///
982/// Expansion is capped at [`MAX_EXPANDED_UIDS`] to prevent OOM when a
983/// server returns extremely large ranges (e.g. `1:4294967295`).
984///
985/// Ranges whose end is `u32::MAX` (the sentinel for `*` in sequence-sets,
986/// per RFC 4731 Section 3.1) are NOT expanded because `*` means "the
987/// highest UID in the mailbox" — a value unknown from the ESEARCH response
988/// alone.  Only the `start` UID is emitted and `truncated` is set to `true`.
989fn expand_uid_ranges(ranges: &[UidRange]) -> (Vec<u32>, bool) {
990    /// Safety cap to prevent OOM from malicious or buggy server responses.
991    const MAX_EXPANDED_UIDS: usize = 1_000_000;
992
993    /// Sentinel value the parser uses for `*` in sequence-sets
994    /// (RFC 4731 Section 3.1 / RFC 3501 Section 9).
995    const STAR_SENTINEL: u32 = u32::MAX;
996
997    let mut uids = Vec::new();
998    let mut truncated = false;
999    for range in ranges {
1000        if let Some(end) = range.end {
1001            // RFC 4731 Section 3.1: `*` in a uid-set means "the highest UID
1002            // in the mailbox."  The parser maps `*` to u32::MAX as a sentinel.
1003            // Since the actual highest UID is unknown from the ESEARCH response
1004            // alone, we cannot expand this range.  Emit only the start UID and
1005            // signal truncation so callers know the result is incomplete.
1006            if end == STAR_SENTINEL {
1007                uids.push(range.start);
1008                truncated = true;
1009                continue;
1010            }
1011            let count = (end.saturating_sub(range.start).saturating_add(1)) as usize;
1012            if uids.len().saturating_add(count) > MAX_EXPANDED_UIDS {
1013                warn!(
1014                    start = range.start,
1015                    end = end,
1016                    "UID range too large to expand ({count} UIDs), \
1017                     truncating to {MAX_EXPANDED_UIDS} total"
1018                );
1019                let remaining = MAX_EXPANDED_UIDS.saturating_sub(uids.len());
1020                if remaining > 0 {
1021                    // remaining > 0 is guaranteed by the guard above.
1022                    // Use saturating_add + min(end) to avoid u32 overflow
1023                    // when range.start is near u32::MAX (RFC 3501 Section 9:
1024                    // nz-number can be up to 4294967295).
1025                    #[allow(clippy::cast_possible_truncation)]
1026                    let last = end.min(range.start.saturating_add((remaining - 1) as u32));
1027                    for uid in range.start..=last {
1028                        uids.push(uid);
1029                    }
1030                }
1031                // RFC 4731 Section 3 / RFC 3501 Section 6.4.4: signal that
1032                // the expanded result does not faithfully represent the full
1033                // server response.
1034                truncated = true;
1035                break;
1036            }
1037            for uid in range.start..=end {
1038                uids.push(uid);
1039            }
1040        } else {
1041            uids.push(range.start);
1042        }
1043    }
1044    (uids, truncated)
1045}
1046
1047/// Build a `SelectedMailbox` from collected untagged and tagged responses.
1048///
1049/// Extracts EXISTS, RECENT, FLAGS, UIDVALIDITY, UIDNEXT, PERMANENTFLAGS,
1050/// HIGHESTMODSEQ, and UIDNOTSTICKY from the responses per RFC 3501 Section 7.
1051fn build_selected_mailbox(
1052    untagged: &[UntaggedResponse],
1053    tagged: &TaggedResponse,
1054    read_only: bool,
1055) -> SelectedMailbox {
1056    let mut exists = 0;
1057    let mut recent = 0;
1058    let mut uid_validity: Option<u32> = None;
1059    let mut uid_next = Option::None;
1060    let mut flags = Vec::new();
1061    let mut permanent_flags = Vec::new();
1062    let mut highest_mod_seq = Option::None;
1063    let mut no_mod_seq = false;
1064    let mut unseen: Option<u32> = None;
1065    // RFC 8474 Section 5.1: unique mailbox identifier from [MAILBOXID (<id>)].
1066    let mut mailbox_id: Option<String> = None;
1067    // RFC 4315 Section 2 / RFC 9051 Section 7.1: [UIDNOTSTICKY] — UIDs are not persistent.
1068    let mut uid_not_sticky = false;
1069    // RFC 7162 Section 3.2.5.2: QRESYNC SELECT responses include
1070    // VANISHED (EARLIER) with UIDs removed since last sync, and
1071    // FETCH responses with changed flags.
1072    let mut vanished = Vec::new();
1073    let mut changed_messages = Vec::new();
1074
1075    let effective_responses = selected_mailbox_effective_responses(untagged);
1076
1077    for resp in effective_responses {
1078        match resp {
1079            UntaggedResponse::Exists(n) => exists = *n,
1080            UntaggedResponse::Recent(n) => recent = *n,
1081            UntaggedResponse::Flags(f) => flags.clone_from(f),
1082            UntaggedResponse::Status {
1083                code: Some(code), ..
1084            } => {
1085                extract_selected_code(
1086                    code,
1087                    &mut uid_validity,
1088                    &mut uid_next,
1089                    &mut permanent_flags,
1090                    &mut highest_mod_seq,
1091                    &mut no_mod_seq,
1092                    &mut unseen,
1093                    &mut mailbox_id,
1094                    &mut uid_not_sticky,
1095                );
1096            }
1097            // RFC 7162 Section 3.2.5.2: capture VANISHED (EARLIER) responses
1098            // sent during QRESYNC SELECT/EXAMINE. Only `earlier: true` responses
1099            // belong to the initial sync; `earlier: false` are unsolicited and
1100            // should not be included in the SelectedMailbox.
1101            UntaggedResponse::Vanished {
1102                earlier: true,
1103                uids,
1104            } => {
1105                vanished.extend_from_slice(uids);
1106            }
1107            // RFC 7162 Section 3.2.5.2: capture FETCH responses with
1108            // updated flags sent during QRESYNC SELECT/EXAMINE.
1109            UntaggedResponse::Fetch(fetch) => {
1110                changed_messages.push((**fetch).clone());
1111            }
1112            _ => {}
1113        }
1114    }
1115
1116    // Also check the tagged response code
1117    if let Some(code) = &tagged.code {
1118        extract_selected_code(
1119            code,
1120            &mut uid_validity,
1121            &mut uid_next,
1122            &mut permanent_flags,
1123            &mut highest_mod_seq,
1124            &mut no_mod_seq,
1125            &mut unseen,
1126            &mut mailbox_id,
1127            &mut uid_not_sticky,
1128        );
1129    }
1130
1131    SelectedMailbox {
1132        exists,
1133        recent,
1134        uid_validity,
1135        uid_next,
1136        flags,
1137        permanent_flags,
1138        highest_mod_seq,
1139        no_mod_seq,
1140        unseen,
1141        mailbox_id,
1142        read_only,
1143        uid_not_sticky,
1144        vanished,
1145        changed_messages,
1146    }
1147}
1148
1149/// Restrict SELECT/EXAMINE processing to responses belonging to the newly
1150/// selected mailbox.
1151///
1152/// RFC 7162 Section 3.2.11: when switching mailboxes, `* OK [CLOSED]`
1153/// separates responses for the previously selected mailbox from the new one.
1154fn selected_mailbox_effective_responses(untagged: &[UntaggedResponse]) -> &[UntaggedResponse] {
1155    match untagged.iter().rposition(|r| {
1156        matches!(
1157            r,
1158            UntaggedResponse::Status {
1159                code: Some(ResponseCode::Closed),
1160                ..
1161            }
1162        )
1163    }) {
1164        Some(closed_idx) => &untagged[closed_idx + 1..],
1165        None => untagged,
1166    }
1167}
1168
1169/// Extract mailbox metadata from a response code (RFC 3501 Section 7.1).
1170#[allow(clippy::too_many_arguments)]
1171fn extract_selected_code(
1172    code: &ResponseCode,
1173    uid_validity: &mut Option<u32>,
1174    uid_next: &mut Option<u32>,
1175    permanent_flags: &mut Vec<Flag>,
1176    highest_mod_seq: &mut Option<u64>,
1177    no_mod_seq: &mut bool,
1178    unseen: &mut Option<u32>,
1179    mailbox_id: &mut Option<String>,
1180    uid_not_sticky: &mut bool,
1181) {
1182    match code {
1183        ResponseCode::UidValidity(v) => *uid_validity = Some(*v),
1184        ResponseCode::UidNext(v) => *uid_next = Some(*v),
1185        ResponseCode::PermanentFlags(f) => permanent_flags.clone_from(f),
1186        ResponseCode::HighestModSeq(v) => {
1187            // RFC 7162 Section 3.1.2.1: mod-sequence-value >= 1.
1188            // HIGHESTMODSEQ 0 is semantically invalid — the server should
1189            // have sent [NOMODSEQ] instead. Treat it equivalently per
1190            // Postel's law (RFC 1122 Section 1.2.2).
1191            if *v == 0 {
1192                *no_mod_seq = true;
1193            } else {
1194                *highest_mod_seq = Some(*v);
1195            }
1196        }
1197        // RFC 7162 Section 3.1.2: [NOMODSEQ] — mailbox does not support
1198        // mod-sequences. Distinct from the server simply not sending
1199        // HIGHESTMODSEQ.
1200        ResponseCode::NoModSeq => *no_mod_seq = true,
1201        // RFC 3501 Section 7.1: [UNSEEN n] — first unseen message sequence number.
1202        // Dropped in IMAP4rev2 (RFC 9051), but commonly sent by rev1 servers.
1203        ResponseCode::Unseen(v) => *unseen = Some(*v),
1204        // RFC 8474 Section 5.1: [MAILBOXID (<id>)] — unique mailbox identifier.
1205        ResponseCode::MailboxId(id) => *mailbox_id = Some(id.clone()),
1206        // RFC 4315 Section 2 / RFC 9051 Section 7.1: [UIDNOTSTICKY] —
1207        // UIDs assigned to messages in this mailbox are not persistent.
1208        ResponseCode::UidNotSticky => *uid_not_sticky = true,
1209        _ => {}
1210    }
1211}
1212
1213/// Check whether a LIST response's attributes conflict with the selection
1214/// options of a LIST-EXTENDED command (RFC 5258 Section 3).
1215///
1216/// When NOTIFY is active, a concurrent create event may match the wildcard
1217/// but lack the attributes required by the selection options (SUBSCRIBED,
1218/// REMOTE, SPECIAL-USE). Returns `true` if the response does NOT satisfy
1219/// the selection criteria and should be treated as a NOTIFY event.
1220pub(super) fn is_notify_selection_mismatch(info: &MailboxInfo, selection_options: &[&str]) -> bool {
1221    let has_recursivematch = selection_options
1222        .iter()
1223        .any(|o| o.eq_ignore_ascii_case("RECURSIVEMATCH"));
1224
1225    for opt in selection_options {
1226        if opt.eq_ignore_ascii_case("SUBSCRIBED") {
1227            let has_subscribed = info
1228                .attributes
1229                .iter()
1230                .any(|a| matches!(a, MailboxAttribute::Subscribed));
1231            // RFC 5258 Section 3.5: with RECURSIVEMATCH, a mailbox can be
1232            // returned without \Subscribed if it has children with matching
1233            // subscriptions (indicated by non-empty CHILDINFO extended data).
1234            let has_childinfo = has_recursivematch && !info.child_info.is_empty();
1235            if !has_subscribed && !has_childinfo {
1236                return true;
1237            }
1238        }
1239        if opt.eq_ignore_ascii_case("REMOTE")
1240            && !info
1241                .attributes
1242                .iter()
1243                .any(|a| matches!(a, MailboxAttribute::Remote))
1244        {
1245            return true;
1246        }
1247        // RFC 6154 Section 3: SPECIAL-USE requests only mailboxes with
1248        // a special-use attribute.
1249        if opt.eq_ignore_ascii_case("SPECIAL-USE")
1250            && !info.attributes.iter().any(MailboxAttribute::is_special_use)
1251        {
1252            return true;
1253        }
1254    }
1255    false
1256}
1257
1258/// Find the index of the first `[NOTIFICATIONOVERFLOW]` response code in a
1259/// stream of untagged responses, or `responses.len()` if there is none.
1260///
1261/// Used by LIST/LIST-EXTENDED/LIST-STATUS handlers to classify each
1262/// Collect solicited FETCH responses from untagged data
1263/// (RFC 3501 Section 7.4.2 / RFC 9051 Section 7.5.2).
1264/// Check whether a LIST response carries markers that identify it as a
1265/// NOTIFY event rather than a solicited LIST result (RFC 5465 Section 5.4).
1266///
1267/// NOTIFY delivers `MailboxName` events (rename, delete, ACL change,
1268/// subscription change) as LIST responses. Reliable markers:
1269///
1270/// - **OLDNAME** (RFC 9051 Section 6.3.9.7): present on rename events.
1271///   A solicited LIST lists current mailbox state and never includes OLDNAME.
1272///   Always treated as a NOTIFY marker.
1273/// - **`\NonExistent`** (RFC 9051 Section 7.2.2): present on delete events.
1274///   A solicited plain LIST only returns existing mailboxes so `\NonExistent`
1275///   is a reliable NOTIFY marker in that context. However, LIST-EXTENDED with
1276///   SUBSCRIBED can legitimately return subscribed-but-deleted mailboxes with
1277///   `\NonExistent` (RFC 5258 Section 3).
1278/// - **`\NoAccess`** (RFC 5465 Section 5.9): present when the logged-in
1279///   user loses the `l` ACL right on a monitored mailbox. A solicited plain
1280///   LIST typically does not return inaccessible mailboxes. However,
1281///   LIST-EXTENDED with SUBSCRIBED may return subscribed-but-inaccessible
1282///   mailboxes with `\NoAccess`.
1283///
1284/// Callers must set `filter_extended_markers` to `false` when
1285/// `\NonExistent` / `\NoAccess` can legitimately appear on solicited
1286/// results (i.e., LIST-EXTENDED with SUBSCRIBED).
1287///
1288/// Create events carry no distinguishing marker and are not filtered.
1289pub(super) fn is_notify_list_event(info: &MailboxInfo, filter_extended_markers: bool) -> bool {
1290    // Rename event: OLDNAME extended data item — always a NOTIFY marker.
1291    if info.old_name.is_some() {
1292        return true;
1293    }
1294    // Delete (\NonExistent) and ACL-loss (\NoAccess) events. Only
1295    // treated as NOTIFY markers when the caller confirms that these
1296    // attributes cannot appear on solicited results (i.e., plain LIST
1297    // or LIST-EXTENDED without SUBSCRIBED).
1298    if filter_extended_markers
1299        && info.attributes.iter().any(|a| {
1300            matches!(
1301                a,
1302                MailboxAttribute::NonExistent | MailboxAttribute::NoAccess
1303            )
1304        })
1305    {
1306        return true;
1307    }
1308    false
1309}