Skip to main content

imap_client/
capabilities.rs

1//! IMAP server capability set.
2//!
3//! Backed by [`imap_core::parser`] for accuracy: the legacy substring
4//! matcher [`Capabilities::parse`] is preserved for callers that hand us
5//! a raw response string, but [`Capabilities::from_atoms`] is preferred —
6//! it is fed the already-tokenised capability atoms from the parser, so
7//! `IMAP4REV2X` no longer false-matches as `IMAP4REV2`.
8
9use imap_core::ast::{DataResponse, Response, ResponseCode};
10use imap_core::parser::parse_response;
11
12/// Parsed view of a server's advertised IMAP capabilities.
13///
14/// Each field is `true` when the corresponding capability atom was present
15/// in the server's `CAPABILITY` list. Unknown atoms are ignored.
16#[derive(Debug, Clone, Default)]
17pub struct Capabilities {
18    /// `IMAP4rev1` (RFC 3501) base protocol.
19    pub imap4rev1: bool,
20    /// `IMAP4rev2` (RFC 9051) base protocol.
21    pub imap4rev2: bool,
22    /// `STARTTLS` — cleartext-to-TLS upgrade is supported.
23    pub starttls: bool,
24    /// `LOGINDISABLED` — the `LOGIN` command is refused (typically pre-TLS).
25    pub login_disabled: bool,
26    /// `IDLE` (RFC 2177) — server-pushed mailbox updates.
27    pub idle: bool,
28    /// `UNSELECT` (RFC 3691) — leave the selected mailbox without expunging.
29    pub unselect: bool,
30    /// `CONDSTORE` (RFC 7162) — conditional store / mod-sequences.
31    pub condstore: bool,
32    /// `QRESYNC` (RFC 7162) — quick mailbox resynchronization.
33    pub qresync: bool,
34    /// `MOVE` (RFC 6851) — atomic message move.
35    pub move_ext: bool,
36    /// `UIDPLUS` (RFC 4315) — `UID EXPUNGE` and assigned-UID responses.
37    pub uidplus: bool,
38    /// `LITERAL+` (RFC 7888) — non-synchronizing literals.
39    pub literal_plus: bool,
40    /// `LITERAL-` (RFC 7888) — non-synchronizing literals capped at 4096 octets.
41    pub literal_minus: bool,
42    /// `ENABLE` (RFC 5161) — opt into extensions for the session.
43    pub enable: bool,
44    /// `AUTH=PLAIN` SASL mechanism.
45    pub auth_plain: bool,
46    /// `AUTH=LOGIN` SASL mechanism.
47    pub auth_login: bool,
48    /// `AUTH=XOAUTH2` SASL mechanism.
49    pub auth_xoauth2: bool,
50    /// `AUTH=OAUTHBEARER` (RFC 7628) SASL mechanism.
51    pub auth_oauthbearer: bool,
52}
53
54impl Capabilities {
55    /// Build from a list of capability atoms (e.g. as produced by the
56    /// parser's `* CAPABILITY …` data response). Unknown atoms are
57    /// ignored.
58    pub fn from_atoms<S: AsRef<str>>(atoms: &[S]) -> Self {
59        let mut caps = Capabilities::default();
60        for a in atoms {
61            caps.set_from_atom(a.as_ref());
62        }
63        caps
64    }
65
66    fn set_from_atom(&mut self, atom: &str) {
67        let upper = atom.to_ascii_uppercase();
68        match upper.as_str() {
69            "IMAP4REV1" => self.imap4rev1 = true,
70            "IMAP4REV2" => self.imap4rev2 = true,
71            "STARTTLS" => self.starttls = true,
72            "LOGINDISABLED" => self.login_disabled = true,
73            "IDLE" => self.idle = true,
74            "UNSELECT" => self.unselect = true,
75            "CONDSTORE" => self.condstore = true,
76            "QRESYNC" => self.qresync = true,
77            "MOVE" => self.move_ext = true,
78            "UIDPLUS" => self.uidplus = true,
79            "LITERAL+" => self.literal_plus = true,
80            "LITERAL-" => self.literal_minus = true,
81            "ENABLE" => self.enable = true,
82            "AUTH=PLAIN" => self.auth_plain = true,
83            "AUTH=LOGIN" => self.auth_login = true,
84            "AUTH=XOAUTH2" => self.auth_xoauth2 = true,
85            "AUTH=OAUTHBEARER" => self.auth_oauthbearer = true,
86            _ => {}
87        }
88    }
89
90    /// Update from a parsed [`Response`] if it carries capabilities — either
91    /// a `* CAPABILITY …` data response or a `[CAPABILITY …]` response code
92    /// on a status response. Returns `true` if anything was updated.
93    pub fn try_update_from(&mut self, response: &Response<'_>) -> bool {
94        match response {
95            Response::Data(DataResponse::Capability(caps)) => {
96                *self = Capabilities::from_atoms(caps);
97                true
98            }
99            Response::Status(s) => match &s.code {
100                Some(ResponseCode::Capability(caps)) => {
101                    *self = Capabilities::from_atoms(caps);
102                    true
103                }
104                _ => false,
105            },
106            _ => false,
107        }
108    }
109
110    /// Best-effort parse of a raw frame. Returns `Some(caps)` if the bytes
111    /// contain a CAPABILITY response (data or response-code); else `None`.
112    pub fn from_frame(frame: &[u8]) -> Option<Self> {
113        let (_, response) = parse_response(frame).ok()?;
114        let mut caps = Capabilities::default();
115        if caps.try_update_from(&response) {
116            Some(caps)
117        } else {
118            None
119        }
120    }
121
122    /// Substring-based parser kept for backward compatibility. Prefer
123    /// [`Self::from_atoms`] or [`Self::from_frame`] — those use the real
124    /// parser and avoid prefix collisions like `IMAP4REV2X`.
125    pub fn parse(response: &str) -> Self {
126        // Try the real parser first.
127        if let Some(caps) = Capabilities::from_frame(response.as_bytes()) {
128            return caps;
129        }
130        // Fallback: tokenise on whitespace and feed atoms.
131        let mut caps = Capabilities::default();
132        for atom in response.split_ascii_whitespace() {
133            caps.set_from_atom(atom);
134        }
135        caps
136    }
137}
138
139#[cfg(test)]
140mod tests {
141    use super::*;
142
143    #[test]
144    fn test_parse_gmail_caps() {
145        let cap_str = "* CAPABILITY IMAP4rev1 UNSELECT IDLE NAMESPACE QUOTA ID XLIST MOVE CONDSTORE ENABLE UTF8=ACCEPT\r\n";
146        let caps = Capabilities::parse(cap_str);
147        assert!(caps.imap4rev1);
148        assert!(!caps.imap4rev2);
149        assert!(caps.move_ext);
150        assert!(caps.condstore);
151        assert!(caps.idle);
152        assert!(caps.unselect);
153        assert!(caps.enable);
154    }
155
156    #[test]
157    fn test_parse_rev2_caps() {
158        let cap_str = "* CAPABILITY IMAP4rev2 MOVE UIDPLUS LITERAL+\r\n";
159        let caps = Capabilities::parse(cap_str);
160        assert!(caps.imap4rev2);
161        assert!(caps.move_ext);
162        assert!(caps.uidplus);
163        assert!(caps.literal_plus);
164    }
165
166    #[test]
167    fn test_capabilities_from_atoms() {
168        let caps = Capabilities::from_atoms(&["IMAP4REV2", "STARTTLS", "AUTH=PLAIN"]);
169        assert!(caps.imap4rev2);
170        assert!(caps.starttls);
171        assert!(caps.auth_plain);
172    }
173
174    #[test]
175    fn test_capabilities_from_response_code() {
176        // OK response carrying [CAPABILITY ...] response code.
177        let frame = b"A1 OK [CAPABILITY IMAP4rev2 STARTTLS] welcome\r\n";
178        let caps = Capabilities::from_frame(frame).unwrap();
179        assert!(caps.imap4rev2);
180        assert!(caps.starttls);
181        assert!(!caps.imap4rev1);
182    }
183
184    #[test]
185    fn test_no_false_prefix_match() {
186        // Hypothetical extension that would have tripped the substring matcher.
187        let caps = Capabilities::from_atoms(&["IMAP4REV2X"]);
188        assert!(!caps.imap4rev2);
189    }
190
191    #[test]
192    fn test_parse_legacy_string_falls_back() {
193        // No CAPABILITY response wrapper — the substring fallback still works.
194        let caps = Capabilities::parse("IMAP4rev2 IDLE STARTTLS");
195        assert!(caps.imap4rev2);
196        assert!(caps.idle);
197        assert!(caps.starttls);
198    }
199}