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}