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}