Skip to main content

daaki_imap/connection/
helpers.rs

1#![allow(clippy::wildcard_imports)]
2use super::*;
3
4/// Compare two decoded mailbox names for command-response correlation.
5///
6/// RFC 3501 Section 5.1: `INBOX` is case-insensitive; all other mailbox
7/// names are compared byte-for-byte. Both arguments must be decoded
8/// (user-facing UTF-8), not wire-form.
9pub(crate) fn inbox_eq(a: &str, b: &str) -> bool {
10    if a.eq_ignore_ascii_case("INBOX") && b.eq_ignore_ascii_case("INBOX") {
11        return true;
12    }
13    a == b
14}
15
16impl ImapConnection {
17    /// Current session state (RFC 3501 §3 / RFC 9051 §3).
18    ///
19    /// Returns a snapshot of the session state as last observed by the
20    /// driver task. State transitions are driven by command responses
21    /// (e.g., `SELECT` moves to `Selected`, `LOGOUT` moves to `Logout`).
22    pub fn session_state(&self) -> SessionState {
23        self.state_rx.borrow().session_state
24    }
25
26    /// Cached server capabilities (RFC 3501 §7.2.1 / RFC 9051 §7.2.1).
27    ///
28    /// Returns a clone of the capability list as last observed by the
29    /// driver task. Updated automatically when the server advertises
30    /// new capabilities (e.g., post-STARTTLS, post-LOGIN).
31    pub fn capabilities(&self) -> Vec<Capability> {
32        self.state_rx.borrow().capabilities.clone()
33    }
34
35    /// Check if `IMAP4rev2` behavior is active (RFC 9051).
36    ///
37    /// Many extension capabilities (`ESEARCH`, `IDLE`, `MOVE`, etc.) are part of the
38    /// `IMAP4rev2` base set and don't need separate capability tokens.
39    ///
40    /// RFC 9051 Section 6.3.1: when both `IMAP4rev1` and `IMAP4rev2` are
41    /// advertised, a client MUST issue `ENABLE IMAP4rev2` before assuming
42    /// rev2 behavior. If the server advertises only `IMAP4rev2` (no rev1),
43    /// rev2 is implicitly active.
44    pub(super) fn is_rev2(&self) -> bool {
45        let snap = self.state_rx.borrow();
46        super::auth::is_rev2_from_snapshot(&snap)
47    }
48
49    /// Check if the server requires UTF8=ACCEPT to be enabled (RFC 6855 Section 3).
50    ///
51    /// When a server advertises `UTF8=ONLY`, clients MUST issue
52    /// `ENABLE UTF8=ACCEPT` before sending commands that use mailbox names
53    /// or string arguments.
54    ///
55    /// On dual-mode servers that advertise both `IMAP4rev1` and `IMAP4rev2`,
56    /// RFC 9051 Appendix A requires the client to issue `ENABLE IMAP4rev2`
57    /// before rev2 UTF-8 quoted-string behavior becomes active. Once that
58    /// happens, the connection is already in the UTF-8-capable mode that
59    /// `UTF8=ONLY` requires.
60    pub(super) fn check_utf8_only_enforced(&self) -> Result<(), Error> {
61        let snap = self.state_rx.borrow();
62        let utf8_enabled = snap
63            .enabled
64            .iter()
65            .any(|e| e.eq_ignore_ascii_case("UTF8=ACCEPT"));
66        let needs_utf8 = snap.capabilities.contains(&Capability::Utf8Only)
67            && !utf8_enabled
68            && !super::auth::is_rev2_from_snapshot(&snap);
69        drop(snap);
70        if needs_utf8 {
71            return Err(Error::Protocol(
72                "server requires ENABLE UTF8=ACCEPT before use \
73                 (UTF8=ONLY advertised, RFC 6855 Section 3)"
74                    .into(),
75            ));
76        }
77        Ok(())
78    }
79
80    /// Ensure the session is in one of the allowed states (RFC 3501 Section 6).
81    pub(super) fn require_state(&self, allowed: &[SessionState]) -> Result<(), Error> {
82        let snap = self.state_rx.borrow();
83        let session_state = snap.session_state;
84        drop(snap);
85        if allowed.contains(&session_state) {
86            Ok(())
87        } else {
88            Err(Error::Protocol(format!(
89                "command not valid in {session_state:?} state (expected one of {allowed:?})"
90            )))
91        }
92    }
93
94    /// Verify that the server supports CONDSTORE (RFC 7162 Section 3.1).
95    ///
96    /// QRESYNC implies CONDSTORE (RFC 7162 Section 3.2.3), so either
97    /// capability satisfies the requirement.
98    pub(super) fn require_condstore(&self) -> Result<(), Error> {
99        let snap = self.state_rx.borrow();
100        let has_condstore = snap.capabilities.contains(&Capability::Condstore)
101            || snap.capabilities.contains(&Capability::QResync);
102        drop(snap);
103        if !has_condstore {
104            return Err(Error::MissingCapability("CONDSTORE".into()));
105        }
106        Ok(())
107    }
108
109    /// Verify that the server advertises the SEARCHRES capability (RFC 5182 Section 2).
110    pub(super) fn require_searchres(&self) -> Result<(), Error> {
111        let snap = self.state_rx.borrow();
112        // RFC 9051 Appendix E: IMAP4rev2 folds SEARCHRES into the base protocol.
113        let has_searchres = snap.capabilities.contains(&Capability::SearchRes)
114            || super::auth::is_rev2_from_snapshot(&snap);
115        drop(snap);
116        if !has_searchres {
117            return Err(Error::MissingCapability("SEARCHRES".into()));
118        }
119        Ok(())
120    }
121
122    /// Returns `true` when a generic SEARCH RETURN command requests the SAVE
123    /// result option from the SEARCHRES extension (RFC 5182 Section 2).
124    pub(super) fn search_return_requests_save(cmd: &Command) -> bool {
125        match cmd {
126            Command::SearchReturn { return_opts, .. }
127            | Command::UidSearchReturn { return_opts, .. } => return_opts
128                .iter()
129                .any(|opt| opt.trim().eq_ignore_ascii_case("SAVE")),
130            _ => false,
131        }
132    }
133
134    /// RFC 7162 Section 3.1.5 defines `MODSEQ` as a SEARCH extension key, so
135    /// SEARCH-family commands must not use it until CONDSTORE or QRESYNC is
136    /// available. SORT and THREAD inherit the same search criteria grammar.
137    pub(super) fn require_condstore_for_modseq_criterion(
138        &self,
139        criteria: &str,
140    ) -> Result<(), Error> {
141        if Self::search_criteria_contains_atom(criteria, "MODSEQ") {
142            self.require_condstore()?;
143        }
144        Ok(())
145    }
146
147    /// Validate SEARCH-family extension criteria against negotiated
148    /// capabilities before sending the command.
149    ///
150    /// RFC 7162 Section 3.1.5 defines `MODSEQ` as a SEARCH extension key,
151    /// while RFC 5032 Sections 2 and 3 add `OLDER` and `YOUNGER` only for
152    /// servers that advertise the WITHIN capability. RFC 8514 Sections 4.1,
153    /// 4.3, and 5 likewise add `SAVEDBEFORE`, `SAVEDON`, `SAVEDSINCE`, and
154    /// `SAVEDATESUPPORTED` only for servers that advertise `SAVEDATE`.
155    /// RFC 8474 Sections 6 and 7 likewise add the `EMAILID` and `THREADID`
156    /// SEARCH keys only for servers that advertise `OBJECTID`.
157    /// RFC 5182 Sections 2.1 and 3 extend SEARCH-family criteria with the
158    /// `$` marker only when SEARCHRES (or `IMAP4rev2`, RFC 9051 Appendix E)
159    /// is available.
160    /// SEARCH RETURN, SORT, and THREAD all reuse the SEARCH criteria grammar,
161    /// so the same gates apply to every SEARCH-family command.
162    pub(super) fn validate_search_criteria_capabilities(
163        &self,
164        criteria: &str,
165    ) -> Result<(), Error> {
166        self.require_condstore_for_modseq_criterion(criteria)?;
167
168        let snap = self.state_rx.borrow();
169
170        if (Self::search_criteria_contains_atom(criteria, "OLDER")
171            || Self::search_criteria_contains_atom(criteria, "YOUNGER"))
172            && !snap.capabilities.contains(&Capability::Within)
173        {
174            return Err(Error::MissingCapability("WITHIN".into()));
175        }
176
177        if ["SAVEDBEFORE", "SAVEDON", "SAVEDSINCE", "SAVEDATESUPPORTED"]
178            .into_iter()
179            .any(|atom| Self::search_criteria_contains_atom(criteria, atom))
180            && !snap.capabilities.contains(&Capability::SaveDate)
181        {
182            return Err(Error::MissingCapability("SAVEDATE".into()));
183        }
184
185        if ["EMAILID", "THREADID"]
186            .into_iter()
187            .any(|atom| Self::search_criteria_contains_atom(criteria, atom))
188            && !snap.capabilities.contains(&Capability::ObjectId)
189        {
190            return Err(Error::MissingCapability("OBJECTID".into()));
191        }
192
193        // Drop the borrow before calling require_searchres (which borrows again).
194        drop(snap);
195
196        if Self::search_criteria_contains_atom(criteria, "$") {
197            self.require_searchres()?;
198        }
199
200        Ok(())
201    }
202
203    /// Returns `true` when `criteria` contains the given search-key atom
204    /// outside quoted strings.
205    ///
206    /// SEARCH, SORT, and THREAD criteria are free-form IMAP search syntax, so
207    /// extension keys such as `MODSEQ` can appear nested inside parenthesized
208    /// groups. RFC 3501 Section 6.4.4 and RFC 9051 Section 6.4.4 also define
209    /// many standard keys whose operands are `astring`, dates, numbers, or
210    /// sequence-sets. The scanner therefore walks the SEARCH grammar and skips
211    /// operands for known keys, so payloads like `HEADER Subject "MODSEQ"`,
212    /// `BODY {12}\r\nhello MODSEQ`, and `BODY MODSEQ` do not trigger extension
213    /// capability checks just because their data happens to equal a gated
214    /// search-key atom.
215    pub(super) fn search_criteria_contains_atom(criteria: &str, atom: &str) -> bool {
216        let bytes = criteria.as_bytes();
217        let mut i = 0usize;
218
219        Self::search_criteria_skip_whitespace(bytes, &mut i);
220
221        // RFC 3501 Section 6.4.4 / RFC 9051 Section 6.4.4: SEARCH criteria
222        // may start with an optional `CHARSET <astring>` prefix.
223        let mut lookahead = i;
224        if let Some(SearchCriteriaItem::Bare(token)) =
225            Self::search_criteria_consume_item(criteria, bytes, &mut lookahead)
226        {
227            if token.eq_ignore_ascii_case("CHARSET") {
228                i = lookahead;
229                let _ = Self::search_criteria_consume_item(criteria, bytes, &mut i);
230            }
231        }
232
233        while i < bytes.len() {
234            if Self::search_criteria_contains_atom_in_key(criteria, bytes, &mut i, atom) {
235                return true;
236            }
237            Self::search_criteria_skip_whitespace(bytes, &mut i);
238        }
239
240        false
241    }
242
243    /// RFC 3501 Section 4.3: a client literal is `{number}` or `{number+}`
244    /// followed by CRLF and exactly `number` octets of data.
245    ///
246    /// SEARCH-family criteria can embed such literals because many search keys
247    /// take `astring` operands (RFC 3501 Section 6.4.4, RFC 5256 Section 5).
248    /// When scanning criteria for extension atoms, the literal payload must be
249    /// skipped verbatim so its contents are not mistaken for syntax.
250    pub(super) fn search_criteria_literal_end(bytes: &[u8], pos: usize) -> Option<usize> {
251        if bytes.get(pos) != Some(&b'{') {
252            return None;
253        }
254
255        let mut j = pos + 1;
256        let digits_start = j;
257        while j < bytes.len() && bytes[j].is_ascii_digit() {
258            j += 1;
259        }
260        if j == digits_start {
261            return None;
262        }
263
264        let digits_end = j;
265        if j < bytes.len() && bytes[j] == b'+' {
266            j += 1;
267        }
268
269        if j + 2 >= bytes.len()
270            || bytes[j] != b'}'
271            || bytes[j + 1] != b'\r'
272            || bytes[j + 2] != b'\n'
273        {
274            return None;
275        }
276
277        let size = std::str::from_utf8(&bytes[digits_start..digits_end])
278            .ok()
279            .and_then(|s| s.parse::<usize>().ok())?;
280        let data_start = j + 3;
281        let data_end = data_start.checked_add(size)?;
282        (data_end <= bytes.len()).then_some(data_end)
283    }
284
285    /// RFC 3501 Section 6.4.4 / RFC 9051 Section 6.4.4: SEARCH keys are
286    /// separated by linear whitespace.
287    pub(super) fn search_criteria_skip_whitespace(bytes: &[u8], pos: &mut usize) {
288        while *pos < bytes.len() && matches!(bytes[*pos], b' ' | b'\t' | b'\r' | b'\n') {
289            *pos += 1;
290        }
291    }
292
293    /// RFC 3501 Section 6.4.4 / RFC 9051 Section 6.4.4: a SEARCH program is a
294    /// sequence of search keys, each of which can be a parenthesized group or
295    /// an atom with zero or more operands.
296    pub(super) fn search_criteria_contains_atom_in_key(
297        criteria: &str,
298        bytes: &[u8],
299        pos: &mut usize,
300        atom: &str,
301    ) -> bool {
302        Self::search_criteria_skip_whitespace(bytes, pos);
303
304        if *pos >= bytes.len() {
305            return false;
306        }
307
308        if bytes[*pos] == b'(' {
309            *pos += 1;
310            loop {
311                Self::search_criteria_skip_whitespace(bytes, pos);
312                if *pos >= bytes.len() {
313                    return false;
314                }
315                if bytes[*pos] == b')' {
316                    *pos += 1;
317                    return false;
318                }
319                if Self::search_criteria_contains_atom_in_key(criteria, bytes, pos, atom) {
320                    return true;
321                }
322            }
323        }
324
325        let item = Self::search_criteria_consume_item(criteria, bytes, pos);
326
327        let Some(SearchCriteriaItem::Bare(token)) = item else {
328            return false;
329        };
330
331        if token.eq_ignore_ascii_case(atom) {
332            return true;
333        }
334
335        let upper = token.to_ascii_uppercase();
336        match upper.as_str() {
337            // 1-operand keys (astring or number/date)
338            "BCC" | "BODY" | "CC" | "FROM" | "KEYWORD" | "SUBJECT" | "TEXT" | "TO"
339            | "UNKEYWORD" | "EMAILID" | "THREADID" | "LARGER" | "SMALLER" | "BEFORE" | "ON"
340            | "SINCE" | "SENTBEFORE" | "SENTON" | "SENTSINCE" | "OLDER" | "YOUNGER"
341            | "SAVEDBEFORE" | "SAVEDON" | "SAVEDSINCE" | "UID" => {
342                let _ = Self::search_criteria_consume_item(criteria, bytes, pos);
343            }
344
345            // HEADER takes two astring operands
346            "HEADER" => {
347                let _ = Self::search_criteria_consume_item(criteria, bytes, pos);
348                let _ = Self::search_criteria_consume_item(criteria, bytes, pos);
349            }
350
351            // NOT takes one search-key operand (handled recursively)
352            "NOT" => {
353                return Self::search_criteria_contains_atom_in_key(criteria, bytes, pos, atom);
354            }
355
356            // OR takes two search-key operands (handled recursively)
357            "OR" => {
358                if Self::search_criteria_contains_atom_in_key(criteria, bytes, pos, atom) {
359                    return true;
360                }
361                return Self::search_criteria_contains_atom_in_key(criteria, bytes, pos, atom);
362            }
363
364            // MODSEQ has a variable number of operands
365            "MODSEQ" => {
366                Self::search_criteria_consume_modseq_operands(criteria, bytes, pos);
367            }
368
369            // Sequence set or unknown key — treat as 0-operand
370            _ => {}
371        }
372
373        false
374    }
375
376    /// Consume the next item from the search criteria stream.
377    ///
378    /// Returns `Some(SearchCriteriaItem::Bare(token))` for a bare atom,
379    /// `Some(SearchCriteriaItem::Quoted)` for a quoted string,
380    /// `Some(SearchCriteriaItem::Literal)` for a literal `{N}\r\n...`,
381    /// or `None` at end-of-input.
382    pub(super) fn search_criteria_consume_item<'a>(
383        criteria: &'a str,
384        bytes: &[u8],
385        pos: &mut usize,
386    ) -> Option<SearchCriteriaItem<'a>> {
387        Self::search_criteria_skip_whitespace(bytes, pos);
388
389        if *pos >= bytes.len() {
390            return None;
391        }
392
393        if bytes[*pos] == b'"' {
394            *pos += 1;
395            while *pos < bytes.len() && bytes[*pos] != b'"' {
396                if bytes[*pos] == b'\\' {
397                    *pos += 1;
398                }
399                *pos += 1;
400            }
401            if *pos < bytes.len() {
402                *pos += 1;
403            }
404            return Some(SearchCriteriaItem::Quoted);
405        }
406
407        if let Some(end) = Self::search_criteria_literal_end(bytes, *pos) {
408            *pos = end;
409            return Some(SearchCriteriaItem::Literal);
410        }
411
412        let start = *pos;
413        while *pos < bytes.len()
414            && !matches!(bytes[*pos], b' ' | b'\t' | b'\r' | b'\n' | b'(' | b')')
415        {
416            *pos += 1;
417        }
418
419        if *pos > start {
420            Some(SearchCriteriaItem::Bare(&criteria[start..*pos]))
421        } else {
422            None
423        }
424    }
425
426    /// RFC 7162 Section 3.1.5: `MODSEQ` is followed by either just
427    /// `mod-sequence-valzer` or by `entry-name SP entry-type-req SP
428    /// mod-sequence-valzer`. The gate only needs to skip those operands so
429    /// later search keys continue to be parsed in the right position.
430    pub(super) fn search_criteria_consume_modseq_operands(
431        criteria: &str,
432        bytes: &[u8],
433        pos: &mut usize,
434    ) {
435        let Some(first) = Self::search_criteria_consume_item(criteria, bytes, pos) else {
436            return;
437        };
438
439        match first {
440            SearchCriteriaItem::Bare(value) if value.bytes().all(|b| b.is_ascii_digit()) => {}
441            _ => {
442                let _ = Self::search_criteria_consume_item(criteria, bytes, pos);
443                let _ = Self::search_criteria_consume_item(criteria, bytes, pos);
444            }
445        }
446    }
447
448    /// `true` when the server advertises support for the named quota resource
449    /// via `QUOTA=RES-<name>` (RFC 9208 Section 3.1.1).
450    pub(super) fn has_quota_resource(&self, resource: &str) -> bool {
451        let snap = self.state_rx.borrow();
452        snap.capabilities.iter().any(|cap| {
453            Self::quota_resource_name(cap).is_some_and(|name| name.eq_ignore_ascii_case(resource))
454        })
455    }
456
457    /// Returns the quota resource name from `QUOTA=RES-<name>`
458    /// capabilities (RFC 9208 Section 3.1.1).
459    pub(super) fn quota_resource_name(cap: &Capability) -> Option<&str> {
460        match cap {
461            Capability::QuotaResource(name) => Some(name.as_str()),
462            Capability::Other(s)
463                if s.len() > "QUOTA=RES-".len()
464                    && s[.."QUOTA=RES-".len()].eq_ignore_ascii_case("QUOTA=RES-") =>
465            {
466                Some(&s["QUOTA=RES-".len()..])
467            }
468            _ => None,
469        }
470    }
471
472    /// Validate LIST-EXTENDED request syntax and capability requirements
473    /// before sending a LIST command with selection options, multiple
474    /// patterns, or return options (RFC 5258 Section 3 /
475    /// RFC 9051 Section 6.3.9 / Appendix C).
476    pub(super) fn validate_list_extended_request(
477        &self,
478        patterns: &[&str],
479        selection_options: &[&str],
480        return_options: &[&str],
481    ) -> Result<(), Error> {
482        if patterns.is_empty() {
483            return Err(Error::Protocol(
484                "LIST-EXTENDED requires at least one mailbox pattern \
485                 (RFC 5258 Section 3 / RFC 9051 Section 6.3.9)"
486                    .into(),
487            ));
488        }
489
490        {
491            let snap = self.state_rx.borrow();
492
493            // RFC 9051 Appendix C keeps RFC 5258's parenthesized multiple-pattern
494            // syntax behind the LIST-EXTENDED capability, even for IMAP4rev2.
495            if patterns.len() > 1 && !snap.capabilities.contains(&Capability::ListExtended) {
496                return Err(Error::MissingCapability("LIST-EXTENDED".into()));
497            }
498
499            // RFC 5258 Section 3 defines selection options and return options as
500            // LIST-EXTENDED syntax on IMAP4rev1, even when the individual option
501            // token comes from another extension such as RFC 6154 SPECIAL-USE or
502            // RFC 5819 LIST-STATUS.
503            let needs_list_extended = !selection_options.is_empty() || !return_options.is_empty();
504
505            if needs_list_extended
506                && !snap.capabilities.contains(&Capability::ListExtended)
507                && !super::auth::is_rev2_from_snapshot(&snap)
508            {
509                return Err(Error::MissingCapability("LIST-EXTENDED".into()));
510            }
511
512            for option in selection_options {
513                let trimmed = option.trim();
514                if trimmed.is_empty() {
515                    return Err(Error::Protocol(
516                        "LIST-EXTENDED selection options must not be empty \
517                         (RFC 5258 Section 3 / RFC 9051 Section 6.3.9)"
518                            .into(),
519                    ));
520                }
521                if trimmed.eq_ignore_ascii_case("SPECIAL-USE")
522                    && !snap.capabilities.contains(&Capability::SpecialUse)
523                {
524                    return Err(Error::MissingCapability("SPECIAL-USE".into()));
525                }
526            }
527        }
528
529        let has_recursivematch = selection_options
530            .iter()
531            .any(|option| option.trim().eq_ignore_ascii_case("RECURSIVEMATCH"));
532        for option in return_options {
533            let trimmed = option.trim();
534            if trimmed.is_empty() {
535                return Err(Error::Protocol(
536                    "LIST-EXTENDED return options must not be empty \
537                     (RFC 5258 Section 3 / RFC 9051 Section 6.3.9)"
538                        .into(),
539                ));
540            }
541
542            {
543                let snap = self.state_rx.borrow();
544                if trimmed.eq_ignore_ascii_case("SPECIAL-USE")
545                    && !snap.capabilities.contains(&Capability::SpecialUse)
546                {
547                    return Err(Error::MissingCapability("SPECIAL-USE".into()));
548                }
549            }
550
551            if let Some(status_items) =
552                Self::list_status_return_option_items(trimmed).transpose()?
553            {
554                {
555                    let snap = self.state_rx.borrow();
556                    if !snap.capabilities.contains(&Capability::ListStatus)
557                        && !super::auth::is_rev2_from_snapshot(&snap)
558                    {
559                        return Err(Error::MissingCapability("LIST-STATUS".into()));
560                    }
561                }
562                self.validate_requested_status_items(status_items)?;
563            }
564        }
565
566        if has_recursivematch
567            && !selection_options.iter().any(|option| {
568                let trimmed = option.trim();
569                !trimmed.is_empty()
570                    && !trimmed.eq_ignore_ascii_case("RECURSIVEMATCH")
571                    && !trimmed.eq_ignore_ascii_case("REMOTE")
572            })
573        {
574            return Err(Error::Protocol(
575                "LIST-EXTENDED selection option RECURSIVEMATCH requires another \
576                 non-REMOTE selection option (RFC 5258 Section 3 / \
577                 RFC 9051 Section 6.3.9)"
578                    .into(),
579            ));
580        }
581
582        Ok(())
583    }
584
585    /// RFC 5819 Section 2 adds the reserved `STATUS (<items>)` return option
586    /// to RFC 5258's `option-extension` grammar, so tokens that merely start
587    /// with `STATUS` remain generic extension names rather than STATUS itself.
588    pub(super) fn list_status_return_option_items(option: &str) -> Option<Result<&str, Error>> {
589        let trimmed = option.trim();
590        if !trimmed
591            .get(..6)
592            .is_some_and(|prefix| prefix.eq_ignore_ascii_case("STATUS"))
593        {
594            return None;
595        }
596
597        // Only the exact STATUS keyword, followed by the reserved `SP "("`
598        // sequence from RFC 5819 Section 2, is LIST-STATUS. A longer atom
599        // such as STATUSX is still an RFC 5258 option-extension.
600        match trimmed.as_bytes().get(6).copied() {
601            Some(next) if next != b' ' && !next.is_ascii_whitespace() && next != b'(' => {
602                return None
603            }
604            _ => {}
605        }
606
607        Some(if let Some(suffix) = trimmed[6..].strip_prefix(" (") {
608            if suffix.ends_with(')') && suffix.len() >= 2 {
609                Ok(&suffix[1..suffix.len() - 1])
610            } else {
611                Err(Error::Protocol(
612                    "LIST-EXTENDED STATUS return option must be STATUS (<items>) \
613                 per RFC 5819 Section 2 / RFC 9051 Section 6.3.9"
614                        .into(),
615                ))
616            }
617        } else {
618            Err(Error::Protocol(
619                "LIST-EXTENDED STATUS return option must be STATUS (<items>) \
620                 per RFC 5819 Section 2 / RFC 9051 Section 6.3.9"
621                    .into(),
622            ))
623        })
624    }
625
626    /// Validate requested STATUS data items against the negotiated protocol
627    /// version and advertised extensions before sending the command.
628    ///
629    /// RFC 3501 Section 6.3.10 defines the `IMAP4rev1` base items
630    /// `MESSAGES`, `RECENT`, `UIDNEXT`, `UIDVALIDITY`, and `UNSEEN`.
631    /// RFC 9051 Section 6.3.11 updates the `IMAP4rev2` base set to
632    /// `MESSAGES`, `UIDNEXT`, `UIDVALIDITY`, `UNSEEN`, `DELETED`, and `SIZE`.
633    /// RFC 9208 Section 4.1.4 additionally allows `DELETED` on
634    /// `IMAP4rev1` when `QUOTA=RES-MESSAGE` is advertised and
635    /// `DELETED-STORAGE` when `QUOTA=RES-STORAGE` is advertised.
636    /// Additional items are gated by their respective extensions:
637    /// `HIGHESTMODSEQ` (RFC 7162 Section 3.1.7), `APPENDLIMIT`
638    /// (RFC 7889 Section 3), and `MAILBOXID` (RFC 8474 Section 5.1).
639    pub(super) fn validate_requested_status_items(&self, items: &str) -> Result<(), Error> {
640        for item in Self::status_item_tokens(items)? {
641            match item.to_ascii_uppercase().as_str() {
642                "RECENT" => {
643                    if self.is_rev2() {
644                        return Err(Error::Protocol(
645                            "STATUS item RECENT was removed in IMAP4rev2 \
646                             (RFC 9051 Section 6.3.11)"
647                                .into(),
648                        ));
649                    }
650                }
651                "DELETED" => {
652                    if !self.is_rev2() && !self.has_quota_resource("MESSAGE") {
653                        return Err(Error::Protocol(
654                            "STATUS item DELETED requires IMAP4rev2 or \
655                             QUOTA=RES-MESSAGE (RFC 9051 Section 6.3.11 / \
656                             RFC 9208 Section 4.1.4)"
657                                .into(),
658                        ));
659                    }
660                }
661                "DELETED-STORAGE" => {
662                    if !self.has_quota_resource("STORAGE") {
663                        return Err(Error::MissingCapability("QUOTA=RES-STORAGE".into()));
664                    }
665                }
666                "SIZE" => {
667                    let snap = self.state_rx.borrow();
668                    if !super::auth::is_rev2_from_snapshot(&snap)
669                        && !snap.capabilities.contains(&Capability::StatusSize)
670                    {
671                        return Err(Error::MissingCapability("STATUS=SIZE".into()));
672                    }
673                }
674                "HIGHESTMODSEQ" => self.require_condstore()?,
675                "APPENDLIMIT" => {
676                    let has_appendlimit = self
677                        .state_rx
678                        .borrow()
679                        .capabilities
680                        .iter()
681                        .any(|cap| matches!(cap, Capability::AppendLimit(_)));
682                    if !has_appendlimit {
683                        return Err(Error::MissingCapability("APPENDLIMIT".into()));
684                    }
685                }
686                "MAILBOXID" => {
687                    let snap = self.state_rx.borrow();
688                    if !snap.capabilities.contains(&Capability::ObjectId) {
689                        return Err(Error::MissingCapability("OBJECTID".into()));
690                    }
691                }
692                _ => {}
693            }
694        }
695        Ok(())
696    }
697
698    /// Validate requested FETCH data items against negotiated extensions
699    /// before sending the command.
700    ///
701    /// RFC 3501 Section 6.4.5 allows extension data items in FETCH requests,
702    /// but clients must not request them unless the corresponding extension
703    /// has been advertised:
704    /// - `MODSEQ` requires CONDSTORE/QRESYNC (RFC 7162 Section 3.1.5).
705    /// - `PREVIEW` requires PREVIEW (RFC 8970 Section 4).
706    /// - On `IMAP4rev1`, `BINARY[...]`, `BINARY.PEEK[...]`, and `BINARY.SIZE[...]`
707    ///   require BINARY (RFC 3516 Sections 4.5.1-4.5.2).
708    /// - On `IMAP4rev2`, those FETCH items are part of the base protocol
709    ///   (RFC 9051 Appendix B).
710    /// - `SAVEDATE` requires SAVEDATE (RFC 8514 Section 3).
711    /// - `EMAILID` / `THREADID` require OBJECTID (RFC 8474 Sections 4 and 7).
712    pub(super) fn validate_requested_fetch_items(&self, items: &[FetchAttr]) -> Result<(), Error> {
713        let snap = self.state_rx.borrow();
714        let is_rev2 = super::auth::is_rev2_from_snapshot(&snap);
715        for item in items {
716            match item {
717                // RFC 7162 Section 3.1.5: MODSEQ requires CONDSTORE/QRESYNC.
718                FetchAttr::ModSeq => {
719                    if !snap.capabilities.contains(&Capability::Condstore)
720                        && !snap.capabilities.contains(&Capability::QResync)
721                    {
722                        return Err(Error::MissingCapability("CONDSTORE".into()));
723                    }
724                }
725                // RFC 8970 Section 4: PREVIEW requires PREVIEW capability.
726                FetchAttr::Preview | FetchAttr::PreviewLazy => {
727                    if !snap.capabilities.contains(&Capability::Preview) {
728                        return Err(Error::MissingCapability("PREVIEW".into()));
729                    }
730                }
731                // RFC 8514 Section 3: SAVEDATE requires SAVEDATE capability.
732                FetchAttr::SaveDate => {
733                    if !snap.capabilities.contains(&Capability::SaveDate) {
734                        return Err(Error::MissingCapability("SAVEDATE".into()));
735                    }
736                }
737                // RFC 8474 Sections 4 and 7: EMAILID/THREADID require OBJECTID.
738                FetchAttr::EmailId | FetchAttr::ThreadId => {
739                    if !snap.capabilities.contains(&Capability::ObjectId) {
740                        return Err(Error::MissingCapability("OBJECTID".into()));
741                    }
742                }
743                // RFC 9051 Appendix B: IMAP4rev2 folds the FETCH side of
744                // RFC 3516 into the base protocol, so explicit BINARY is
745                // only required on IMAP4rev1.
746                FetchAttr::Binary { .. } | FetchAttr::BinarySize { .. } => {
747                    if !snap.capabilities.contains(&Capability::Binary) && !is_rev2 {
748                        return Err(Error::MissingCapability("BINARY".into()));
749                    }
750                }
751                _ => {}
752            }
753        }
754        Ok(())
755    }
756
757    /// Parse a STATUS data item list into individual item atoms.
758    ///
759    /// Accepts either a raw space-separated list (`"MESSAGES UNSEEN"`) or a
760    /// parenthesized `status-att-list` (`"(MESSAGES UNSEEN)"`). The list must
761    /// contain at least one item, and nested or unbalanced parentheses are
762    /// rejected per RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11.
763    pub(super) fn status_item_tokens(items: &str) -> Result<Vec<&str>, Error> {
764        let trimmed = items.trim();
765        if trimmed.is_empty() {
766            return Err(Error::Protocol(
767                "STATUS item list must contain at least one data item \
768                 (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
769                    .into(),
770            ));
771        }
772
773        let body = match (trimmed.strip_prefix('('), trimmed.strip_suffix(')')) {
774            (Some(without_open), Some(_)) => {
775                let inner = without_open
776                    .strip_suffix(')')
777                    .ok_or_else(|| {
778                        Error::Protocol(
779                            "STATUS item list must use balanced parentheses \
780                             (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
781                                .into(),
782                        )
783                    })?
784                    .trim();
785                if inner.is_empty() {
786                    return Err(Error::Protocol(
787                        "STATUS item list must contain at least one data item \
788                         (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
789                            .into(),
790                    ));
791                }
792                inner
793            }
794            (Some(_), None) | (None, Some(_)) => {
795                return Err(Error::Protocol(
796                    "STATUS item list must use balanced parentheses \
797                     (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
798                        .into(),
799                ));
800            }
801            (None, None) => trimmed,
802        };
803
804        if body.contains('(') || body.contains(')') {
805            return Err(Error::Protocol(
806                "STATUS item list must be flat and must not contain nested parentheses \
807                 (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
808                    .into(),
809            ));
810        }
811
812        let tokens: Vec<&str> = body.split_ascii_whitespace().collect();
813        if tokens.is_empty() {
814            return Err(Error::Protocol(
815                "STATUS item list must contain at least one data item \
816                 (RFC 3501 Section 6.3.10 / RFC 9051 Section 6.3.11)"
817                    .into(),
818            ));
819        }
820        Ok(tokens)
821    }
822
823    /// Check if the server supports non-synchronizing literals of the given size.
824    ///
825    /// RFC 7888 Section 4: LITERAL+ allows any size.
826    /// RFC 7888 Section 5 / RFC 9051 Section 4.3: LITERAL- style
827    /// non-synchronizing literals are limited to 4096 octets.
828    /// RFC 9051 Appendix E item 2 folds LITERAL- into pure `IMAP4rev2`.
829    pub(super) fn supports_non_sync_literal(&self, size: usize) -> bool {
830        let snap = self.state_rx.borrow();
831        snap.capabilities.contains(&Capability::LiteralPlus)
832            || ((snap.capabilities.contains(&Capability::LiteralMinus)
833                || super::auth::is_rev2_from_snapshot(&snap))
834                && size <= 4096)
835    }
836
837    /// Determine the APPEND literal syntax required for `message`.
838    ///
839    /// RFC 3501 Section 4.3 / RFC 9051 Section 4.3: classic literals carry
840    /// `CHAR8`, which excludes NUL octets.
841    /// RFC 3516 Section 4.4: APPEND data containing NULs requires `literal8`
842    /// and the `BINARY` capability.
843    /// RFC 6855 Section 4: after ENABLE UTF8=ACCEPT, APPEND message data with
844    /// UTF-8 headers must use the `UTF8 (literal8)` wrapper.
845    pub(super) fn append_literal_kind(&self, message: &[u8]) -> Result<AppendLiteralKind, Error> {
846        if self.utf8_enabled() {
847            return Ok(AppendLiteralKind::Utf8Literal8);
848        }
849
850        if message.contains(&0) {
851            let snap = self.state_rx.borrow();
852            if snap.capabilities.contains(&Capability::Binary) {
853                Ok(AppendLiteralKind::Literal8)
854            } else {
855                Err(Error::Protocol(
856                    "APPEND data containing NUL requires BINARY literal8 support \
857                     (RFC 3516 Section 4.4)"
858                        .into(),
859                ))
860            }
861        } else {
862            Ok(AppendLiteralKind::Literal)
863        }
864    }
865
866    /// Check if the server supports non-synchronizing literal8 of the given size.
867    ///
868    /// RFC 7888 Section 6: on `IMAP4rev1`, literal8 may use the
869    /// non-synchronizing form only when BOTH BINARY and a compatible
870    /// literal extension are advertised.
871    ///
872    /// RFC 9051 Section 9 redefines `literal8` for pure `IMAP4rev2` as
873    /// `~{" number64 "}" CRLF *OCTET`, with no `+` modifier, so rev2
874    /// literal8 is always synchronizing.
875    pub(super) fn supports_non_sync_literal8(&self, size: usize) -> bool {
876        let snap = self.state_rx.borrow();
877        if !snap.capabilities.contains(&Capability::Binary)
878            || super::auth::is_rev2_from_snapshot(&snap)
879        {
880            return false;
881        }
882
883        snap.capabilities.contains(&Capability::LiteralPlus)
884            || (snap.capabilities.contains(&Capability::LiteralMinus) && size <= 4096)
885    }
886
887    /// Check if this APPEND literal form may use the non-synchronizing marker.
888    ///
889    /// RFC 7888 Sections 4-6: classic literals follow LITERAL+/LITERAL- rules,
890    /// while `literal8` requires both `BINARY` and a compatible literal
891    /// extension for the `+` suffix.
892    pub(super) fn append_literal_is_non_sync(&self, kind: AppendLiteralKind, size: usize) -> bool {
893        match kind {
894            AppendLiteralKind::Literal => self.supports_non_sync_literal(size),
895            AppendLiteralKind::Literal8 | AppendLiteralKind::Utf8Literal8 => {
896                self.supports_non_sync_literal8(size)
897            }
898        }
899    }
900
901    /// Whether `UTF8=ACCEPT` has been enabled (RFC 6855 Section 3).
902    ///
903    /// Derived from the connection state snapshot. Does NOT include
904    /// `IMAP4rev2` — use `utf8_mode()` for the combined check.
905    pub(super) fn utf8_enabled(&self) -> bool {
906        self.state_rx
907            .borrow()
908            .enabled
909            .iter()
910            .any(|e| e.eq_ignore_ascii_case("UTF8=ACCEPT"))
911    }
912
913    /// Determine the [`LiteralMode`] based on the server's advertised capabilities.
914    ///
915    /// RFC 7888 Section 4: LITERAL+ — non-synchronizing literals of any size.
916    /// RFC 7888 Section 5: LITERAL- — non-synchronizing literals up to 4096 bytes.
917    /// RFC 9051 Appendix E item 2 / Section 4.3: pure `IMAP4rev2` includes the
918    /// same 4096-octet non-synchronizing literal behavior as LITERAL-.
919    /// RFC 3501 Section 4.3: otherwise, literals are synchronizing.
920    pub(super) fn literal_mode(&self) -> LiteralMode {
921        let snap = self.state_rx.borrow();
922        if snap.capabilities.contains(&Capability::LiteralPlus) {
923            LiteralMode::LiteralPlus
924        } else if snap.capabilities.contains(&Capability::LiteralMinus)
925            || super::auth::is_rev2_from_snapshot(&snap)
926        {
927            LiteralMode::LiteralMinus
928        } else {
929            LiteralMode::Synchronizing
930        }
931    }
932
933    // -----------------------------------------------------------------------
934    // NOOP (RFC 3501 Section 6.1.2 / RFC 9051 Section 6.1.2)
935    // -----------------------------------------------------------------------
936
937    /// NOOP — no operation (RFC 3501 Section 6.1.2 / RFC 9051 Section 6.1.2).
938    ///
939    /// Sends a NOOP command to the server. Since the NOOP command can
940    /// include unsolicited responses from the server, this is the
941    /// recommended method for polling for new messages or status updates.
942    ///
943    /// Valid in any state (RFC 3501 Section 6.1.2). Returns an error only
944    /// if the connection is closed (e.g. after LOGOUT).
945    pub async fn noop(&self, timeout: Duration) -> Result<(), Error> {
946        tokio::time::timeout(
947            timeout,
948            self.submit_regular(Command::Noop, super::dispatch::TaggedOkConsumer::default()),
949        )
950        .await
951        .map_err(|_| Error::Timeout)?
952    }
953
954    // -----------------------------------------------------------------------
955    // CHECK (RFC 3501 Section 6.4.1)
956    // -----------------------------------------------------------------------
957
958    /// CHECK — request a checkpoint of the currently selected mailbox
959    /// (RFC 3501 Section 6.4.1).
960    ///
961    /// Implementation-defined server action; typically flushes internal
962    /// state to persistent storage.
963    ///
964    /// **`IMAP4rev1` only.** RFC 9051 removed the CHECK command from
965    /// `IMAP4rev2`. On a pure rev2 connection, use [`noop`](Self::noop)
966    /// instead — this method returns [`Error::Protocol`].
967    pub async fn check(&self, timeout: Duration) -> Result<(), Error> {
968        self.require_state(&[SessionState::Selected])?;
969        // RFC 9051 removed CHECK; reject when rev2 behavior is active.
970        // `is_rev2_from_snapshot` already handles dual-mode servers: it
971        // returns true only after `ENABLE IMAP4rev2` (or on pure-rev2
972        // servers). Once rev2 is active, CHECK is undefined.
973        if self.is_rev2() {
974            return Err(Error::Protocol(
975                "CHECK is not defined in IMAP4rev2 (RFC 9051); use NOOP instead".into(),
976            ));
977        }
978        tokio::time::timeout(
979            timeout,
980            self.submit_regular(Command::Check, super::dispatch::TaggedOkConsumer::default()),
981        )
982        .await
983        .map_err(|_| Error::Timeout)?
984    }
985
986    // -----------------------------------------------------------------------
987    // CAPABILITY (RFC 3501 Section 6.1.1 / RFC 9051 Section 6.1.1)
988    // -----------------------------------------------------------------------
989
990    /// CAPABILITY — force a capability round-trip (RFC 3501 Section 6.1.1 /
991    /// RFC 9051 Section 6.1.1).
992    ///
993    /// Sends an explicit CAPABILITY command and returns the server's
994    /// response. Unlike [`capabilities`](Self::capabilities), which
995    /// returns the cached capability list, this method always performs a
996    /// network round-trip and updates the cached state.
997    ///
998    /// Valid in any state (RFC 3501 Section 6.1.1).
999    pub async fn capability(&self, timeout: Duration) -> Result<Vec<Capability>, Error> {
1000        tokio::time::timeout(
1001            timeout,
1002            self.submit_regular(
1003                Command::Capability,
1004                super::dispatch::CapabilityConsumer::default(),
1005            ),
1006        )
1007        .await
1008        .map_err(|_| Error::Timeout)?
1009    }
1010}
1011
1012/// Item from a SEARCH criteria scan.
1013pub(super) enum SearchCriteriaItem<'a> {
1014    Bare(&'a str),
1015    Quoted,
1016    Literal,
1017}