Skip to main content

stackforge_core/layer/imap/
mod.rs

1//! IMAP (Internet Message Access Protocol) layer implementation.
2//!
3//! Implements RFC 3501 `IMAP4rev1` packet parsing as a zero-copy view into a packet buffer.
4//!
5//! ## Protocol Overview
6//!
7//! IMAP operates over TCP port 143 (993 for IMAPS).
8//! Unlike POP3, IMAP keeps messages on the server and supports folders,
9//! multiple clients, and partial message fetching.
10//!
11//! ## Message Format
12//!
13//! **Client Command:**
14//! ```text
15//! tag COMMAND [arguments]\r\n
16//! ```
17//! Where `tag` is an alphanumeric identifier assigned by the client (e.g., "A001").
18//!
19//! **Server Untagged Response (data/status):**
20//! ```text
21//! * STATUS [data]\r\n
22//! * NUMBER TYPE [data]\r\n
23//! ```
24//!
25//! **Server Tagged Response (command completion):**
26//! ```text
27//! tag OK [text]\r\n
28//! tag NO [text]\r\n
29//! tag BAD [text]\r\n
30//! ```
31//!
32//! **Server Continuation Request:**
33//! ```text
34//! + [text]\r\n
35//! ```
36//!
37//! ## Server Response Status Codes
38//!
39//! | Code     | Meaning                              |
40//! |----------|--------------------------------------|
41//! | OK       | Command completed successfully       |
42//! | NO       | Command failed                       |
43//! | BAD      | Protocol error                       |
44//! | BYE      | Server closing connection            |
45//! | PREAUTH  | Already authenticated                |
46//!
47//! ## Common IMAP Commands (RFC 3501)
48//!
49//! | Command      | State | Description                        |
50//! |--------------|-------|------------------------------------|
51//! | CAPABILITY   | Any   | List server capabilities           |
52//! | NOOP         | Any   | No-op                              |
53//! | LOGOUT       | Any   | End session                        |
54//! | AUTHENTICATE | NonAuth| SASL authentication               |
55//! | LOGIN        | NonAuth| Plaintext login                   |
56//! | STARTTLS     | NonAuth| TLS upgrade (RFC 2595)            |
57//! | SELECT       | Auth  | Select mailbox (read-write)        |
58//! | EXAMINE      | Auth  | Select mailbox (read-only)         |
59//! | CREATE       | Auth  | Create mailbox                     |
60//! | DELETE       | Auth  | Delete mailbox                     |
61//! | RENAME       | Auth  | Rename mailbox                     |
62//! | SUBSCRIBE    | Auth  | Add to subscription list           |
63//! | UNSUBSCRIBE  | Auth  | Remove from subscription list      |
64//! | LIST         | Auth  | List mailboxes                     |
65//! | LSUB         | Auth  | List subscribed mailboxes          |
66//! | STATUS       | Auth  | Request mailbox status             |
67//! | APPEND       | Auth  | Append message to mailbox          |
68//! | CHECK        | Select| Checkpoint mailbox                 |
69//! | CLOSE        | Select| Close selected mailbox             |
70//! | EXPUNGE      | Select| Remove deleted messages            |
71//! | SEARCH       | Select| Search messages                    |
72//! | FETCH        | Select| Retrieve message data              |
73//! | STORE        | Select| Alter message flags                |
74//! | COPY         | Select| Copy messages to another mailbox  |
75//! | UID          | Select| UID variant of COPY/FETCH/SEARCH/STORE|
76
77pub mod builder;
78pub use builder::ImapBuilder;
79
80use crate::layer::field::{FieldError, FieldValue};
81use crate::layer::{Layer, LayerIndex, LayerKind};
82
83/// Minimum IMAP payload size.
84pub const IMAP_MIN_HEADER_LEN: usize = 4;
85
86/// IMAP standard port.
87pub const IMAP_PORT: u16 = 143;
88
89/// IMAPS (over TLS) port.
90pub const IMAPS_PORT: u16 = 993;
91
92// ============================================================================
93// IMAP command names
94// ============================================================================
95pub const CMD_CAPABILITY: &str = "CAPABILITY";
96pub const CMD_NOOP: &str = "NOOP";
97pub const CMD_LOGOUT: &str = "LOGOUT";
98pub const CMD_AUTHENTICATE: &str = "AUTHENTICATE";
99pub const CMD_LOGIN: &str = "LOGIN";
100pub const CMD_STARTTLS: &str = "STARTTLS";
101pub const CMD_SELECT: &str = "SELECT";
102pub const CMD_EXAMINE: &str = "EXAMINE";
103pub const CMD_CREATE: &str = "CREATE";
104pub const CMD_DELETE: &str = "DELETE";
105pub const CMD_RENAME: &str = "RENAME";
106pub const CMD_SUBSCRIBE: &str = "SUBSCRIBE";
107pub const CMD_UNSUBSCRIBE: &str = "UNSUBSCRIBE";
108pub const CMD_LIST: &str = "LIST";
109pub const CMD_LSUB: &str = "LSUB";
110pub const CMD_STATUS: &str = "STATUS";
111pub const CMD_APPEND: &str = "APPEND";
112pub const CMD_CHECK: &str = "CHECK";
113pub const CMD_CLOSE: &str = "CLOSE";
114pub const CMD_EXPUNGE: &str = "EXPUNGE";
115pub const CMD_SEARCH: &str = "SEARCH";
116pub const CMD_FETCH: &str = "FETCH";
117pub const CMD_STORE: &str = "STORE";
118pub const CMD_COPY: &str = "COPY";
119pub const CMD_UID: &str = "UID";
120
121pub static IMAP_COMMANDS: &[&str] = &[
122    "CAPABILITY",
123    "NOOP",
124    "LOGOUT",
125    "AUTHENTICATE",
126    "LOGIN",
127    "STARTTLS",
128    "SELECT",
129    "EXAMINE",
130    "CREATE",
131    "DELETE",
132    "RENAME",
133    "SUBSCRIBE",
134    "UNSUBSCRIBE",
135    "LIST",
136    "LSUB",
137    "STATUS",
138    "APPEND",
139    "CHECK",
140    "CLOSE",
141    "EXPUNGE",
142    "SEARCH",
143    "FETCH",
144    "STORE",
145    "COPY",
146    "UID",
147];
148
149/// IMAP tagged response status strings.
150pub const STATUS_OK: &str = "OK";
151pub const STATUS_NO: &str = "NO";
152pub const STATUS_BAD: &str = "BAD";
153pub const STATUS_BYE: &str = "BYE";
154pub const STATUS_PREAUTH: &str = "PREAUTH";
155
156/// Field names for Python/generic access.
157pub static IMAP_FIELD_NAMES: &[&str] = &[
158    "tag",
159    "command",
160    "args",
161    "status",
162    "text",
163    "is_untagged",
164    "is_continuation",
165    "is_tagged_response",
166    "is_client_command",
167    "raw",
168];
169
170// ============================================================================
171// Payload detection
172// ============================================================================
173
174/// Returns true if `buf` looks like an IMAP payload.
175#[must_use]
176pub fn is_imap_payload(buf: &[u8]) -> bool {
177    if buf.len() < 3 {
178        return false;
179    }
180    // Untagged response: "* "
181    if buf.starts_with(b"* ") {
182        return true;
183    }
184    // Continuation: "+ "
185    if buf.starts_with(b"+ ") || buf == b"+\r\n" || buf == b"+ \r\n" {
186        return true;
187    }
188    // Check for tagged command or tagged response
189    if let Ok(text) = std::str::from_utf8(buf) {
190        let first_line = text.lines().next().unwrap_or("");
191        let parts: Vec<&str> = first_line.splitn(3, ' ').collect();
192        if parts.len() >= 2 {
193            let tag = parts[0];
194            let cmd_or_status = parts[1].to_ascii_uppercase();
195            // Tag should be alphanumeric
196            if !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric()) {
197                // It's a tagged response: tag + status
198                if matches!(
199                    cmd_or_status.as_str(),
200                    "OK" | "NO" | "BAD" | "BYE" | "PREAUTH"
201                ) {
202                    return true;
203                }
204                // It's a client command: tag + COMMAND
205                if IMAP_COMMANDS.contains(&cmd_or_status.as_str()) {
206                    return true;
207                }
208            }
209        }
210    }
211    false
212}
213
214// ============================================================================
215// ImapLayer - zero-copy view
216// ============================================================================
217
218/// A zero-copy view into an IMAP layer within a packet buffer.
219#[must_use]
220#[derive(Debug, Clone)]
221pub struct ImapLayer {
222    pub index: LayerIndex,
223}
224
225impl ImapLayer {
226    pub fn new(index: LayerIndex) -> Self {
227        Self { index }
228    }
229
230    pub fn at_start(len: usize) -> Self {
231        Self {
232            index: LayerIndex::new(LayerKind::Imap, 0, len),
233        }
234    }
235
236    #[inline]
237    fn slice<'a>(&self, buf: &'a [u8]) -> &'a [u8] {
238        let end = self.index.end.min(buf.len());
239        &buf[self.index.start..end]
240    }
241
242    fn first_line<'a>(&self, buf: &'a [u8]) -> &'a str {
243        let s = self.slice(buf);
244        let text = std::str::from_utf8(s).unwrap_or("");
245        text.lines().next().unwrap_or("").trim_end_matches('\r')
246    }
247
248    /// Returns true if this is an untagged server response (starts with "* ").
249    #[must_use]
250    pub fn is_untagged(&self, buf: &[u8]) -> bool {
251        let s = self.slice(buf);
252        s.starts_with(b"* ")
253    }
254
255    /// Returns true if this is a continuation request (starts with "+ ").
256    #[must_use]
257    pub fn is_continuation(&self, buf: &[u8]) -> bool {
258        let s = self.slice(buf);
259        s.starts_with(b"+ ")
260    }
261
262    /// Returns true if this is a tagged server response.
263    #[must_use]
264    pub fn is_tagged_response(&self, buf: &[u8]) -> bool {
265        if self.is_untagged(buf) || self.is_continuation(buf) {
266            return false;
267        }
268        let line = self.first_line(buf);
269        let parts: Vec<&str> = line.splitn(3, ' ').collect();
270        if parts.len() < 2 {
271            return false;
272        }
273        let status = parts[1].to_ascii_uppercase();
274        matches!(status.as_str(), "OK" | "NO" | "BAD" | "BYE" | "PREAUTH")
275    }
276
277    /// Returns true if this is a client command.
278    #[must_use]
279    pub fn is_client_command(&self, buf: &[u8]) -> bool {
280        if self.is_untagged(buf) || self.is_continuation(buf) {
281            return false;
282        }
283        let line = self.first_line(buf);
284        let parts: Vec<&str> = line.splitn(3, ' ').collect();
285        if parts.len() < 2 {
286            return false;
287        }
288        let cmd = parts[1].to_ascii_uppercase();
289        IMAP_COMMANDS.contains(&cmd.as_str())
290    }
291
292    /// Returns the tag from a tagged command or response.
293    ///
294    /// Returns "*" for untagged, "+" for continuation.
295    ///
296    /// # Errors
297    ///
298    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
299    pub fn tag(&self, buf: &[u8]) -> Result<String, FieldError> {
300        let s = self.slice(buf);
301        if s.starts_with(b"* ") {
302            return Ok("*".to_string());
303        }
304        if s.starts_with(b"+ ") {
305            return Ok("+".to_string());
306        }
307        let text = std::str::from_utf8(s)
308            .map_err(|_| FieldError::InvalidValue("tag: non-UTF8 payload".into()))?;
309        let tag = text.split_ascii_whitespace().next().unwrap_or("");
310        Ok(tag.to_string())
311    }
312
313    /// Returns the command verb for a client command.
314    ///
315    /// # Errors
316    ///
317    /// Returns [`FieldError::InvalidValue`] if the IMAP line cannot be parsed.
318    pub fn command(&self, buf: &[u8]) -> Result<String, FieldError> {
319        let line = self.first_line(buf);
320        // Untagged: "* <number> <command> ..." or "* <status> ..."
321        if let Some(rest) = line.strip_prefix("* ") {
322            let word = rest.split_once(' ').map_or(rest, |(w, _)| w);
323            return Ok(word.to_ascii_uppercase());
324        }
325        // Continuation
326        if line.starts_with("+ ") {
327            return Ok("+".to_string());
328        }
329        // Tagged (client or server): "tag CMD/STATUS args"
330        let parts: Vec<&str> = line.splitn(3, ' ').collect();
331        if parts.len() >= 2 {
332            Ok(parts[1].to_ascii_uppercase())
333        } else {
334            Err(FieldError::InvalidValue(
335                "command: cannot parse command from IMAP line".into(),
336            ))
337        }
338    }
339
340    /// Returns the arguments / data portion of the line.
341    ///
342    /// # Errors
343    ///
344    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
345    pub fn args(&self, buf: &[u8]) -> Result<String, FieldError> {
346        let line = self.first_line(buf);
347        // Untagged
348        if let Some(rest) = line.strip_prefix("* ") {
349            let args = rest.split_once(' ').map_or("", |(_, a)| a).trim_start();
350            return Ok(args.to_string());
351        }
352        // Continuation
353        if let Some(rest) = line.strip_prefix("+ ") {
354            return Ok(rest.trim().to_string());
355        }
356        // Tagged
357        let parts: Vec<&str> = line.splitn(3, ' ').collect();
358        if parts.len() >= 3 {
359            Ok(parts[2].trim().to_string())
360        } else {
361            Ok(String::new())
362        }
363    }
364
365    /// Returns the status from a tagged or untagged server response (OK/NO/BAD/BYE/PREAUTH).
366    ///
367    /// # Errors
368    ///
369    /// Returns [`FieldError::InvalidValue`] if the command verb is not a known status keyword.
370    pub fn status(&self, buf: &[u8]) -> Result<String, FieldError> {
371        let cmd = self.command(buf)?;
372        if matches!(cmd.as_str(), "OK" | "NO" | "BAD" | "BYE" | "PREAUTH") {
373            Ok(cmd)
374        } else {
375            Err(FieldError::InvalidValue(
376                "status: not a server status response".into(),
377            ))
378        }
379    }
380
381    /// Returns the text body of a server response (after the status code).
382    ///
383    /// # Errors
384    ///
385    /// Returns [`FieldError::InvalidValue`] if the payload is not valid UTF-8.
386    pub fn text(&self, buf: &[u8]) -> Result<String, FieldError> {
387        let args = self.args(buf)?;
388        Ok(args)
389    }
390
391    /// Returns the raw payload as a string.
392    #[must_use]
393    pub fn raw(&self, buf: &[u8]) -> String {
394        String::from_utf8_lossy(self.slice(buf)).to_string()
395    }
396
397    pub fn get_field(&self, buf: &[u8], name: &str) -> Option<Result<FieldValue, FieldError>> {
398        match name {
399            "tag" => Some(self.tag(buf).map(FieldValue::Str)),
400            "command" => Some(self.command(buf).map(FieldValue::Str)),
401            "args" => Some(self.args(buf).map(FieldValue::Str)),
402            "status" => Some(self.status(buf).map(FieldValue::Str)),
403            "text" => Some(self.text(buf).map(FieldValue::Str)),
404            "is_untagged" => Some(Ok(FieldValue::Bool(self.is_untagged(buf)))),
405            "is_continuation" => Some(Ok(FieldValue::Bool(self.is_continuation(buf)))),
406            "is_tagged_response" => Some(Ok(FieldValue::Bool(self.is_tagged_response(buf)))),
407            "is_client_command" => Some(Ok(FieldValue::Bool(self.is_client_command(buf)))),
408            "raw" => Some(Ok(FieldValue::Str(self.raw(buf)))),
409            _ => None,
410        }
411    }
412}
413
414impl Layer for ImapLayer {
415    fn kind(&self) -> LayerKind {
416        LayerKind::Imap
417    }
418
419    fn summary(&self, buf: &[u8]) -> String {
420        let s = self.slice(buf);
421        let text = String::from_utf8_lossy(s);
422        let first_line = text.lines().next().unwrap_or("").trim_end_matches('\r');
423        format!("IMAP {first_line}")
424    }
425
426    fn header_len(&self, buf: &[u8]) -> usize {
427        self.slice(buf).len()
428    }
429
430    fn hashret(&self, buf: &[u8]) -> Vec<u8> {
431        if let Ok(tag) = self.tag(buf) {
432            tag.into_bytes()
433        } else {
434            vec![]
435        }
436    }
437
438    fn field_names(&self) -> &'static [&'static str] {
439        IMAP_FIELD_NAMES
440    }
441}
442
443/// Returns a human-readable display of IMAP layer fields.
444#[must_use]
445pub fn imap_show_fields(l: &ImapLayer, buf: &[u8]) -> Vec<(&'static str, String)> {
446    let mut fields = Vec::new();
447    if let Ok(tag) = l.tag(buf) {
448        fields.push(("tag", tag));
449    }
450    if let Ok(cmd) = l.command(buf) {
451        fields.push(("command", cmd));
452        if let Ok(args) = l.args(buf)
453            && !args.is_empty()
454        {
455            fields.push(("args", args));
456        }
457    }
458    fields.push(("is_untagged", l.is_untagged(buf).to_string()));
459    fields
460}
461
462#[cfg(test)]
463mod tests {
464    use super::*;
465    use crate::layer::LayerIndex;
466
467    fn make_layer(data: &[u8]) -> ImapLayer {
468        ImapLayer::new(LayerIndex::new(LayerKind::Imap, 0, data.len()))
469    }
470
471    #[test]
472    fn test_imap_detection_untagged() {
473        assert!(is_imap_payload(b"* OK IMAP4rev1 server ready\r\n"));
474        assert!(is_imap_payload(b"* 3 EXISTS\r\n"));
475        assert!(is_imap_payload(b"* BYE server closing\r\n"));
476        assert!(is_imap_payload(b"* CAPABILITY IMAP4rev1 AUTH=PLAIN\r\n"));
477    }
478
479    #[test]
480    fn test_imap_detection_continuation() {
481        assert!(is_imap_payload(b"+ go ahead\r\n"));
482        assert!(is_imap_payload(b"+ \r\n"));
483    }
484
485    #[test]
486    fn test_imap_detection_tagged_response() {
487        assert!(is_imap_payload(b"A001 OK LOGIN completed\r\n"));
488        assert!(is_imap_payload(b"A002 NO login failed\r\n"));
489        assert!(is_imap_payload(b"A003 BAD command unknown\r\n"));
490    }
491
492    #[test]
493    fn test_imap_detection_client_command() {
494        assert!(is_imap_payload(b"A001 LOGIN user pass\r\n"));
495        assert!(is_imap_payload(b"A002 SELECT INBOX\r\n"));
496        assert!(is_imap_payload(b"A003 FETCH 1:* FLAGS\r\n"));
497        assert!(is_imap_payload(b"A004 NOOP\r\n"));
498        assert!(is_imap_payload(b"A005 LOGOUT\r\n"));
499    }
500
501    #[test]
502    fn test_imap_detection_negative() {
503        assert!(!is_imap_payload(b""));
504        assert!(!is_imap_payload(b"GET / HTTP/1.1\r\n"));
505        assert!(!is_imap_payload(b"+OK POP3 server ready\r\n")); // POP3, not IMAP
506    }
507
508    #[test]
509    fn test_imap_untagged_response() {
510        let data = b"* OK IMAP4rev1 Service Ready\r\n";
511        let layer = make_layer(data);
512        assert!(layer.is_untagged(data));
513        assert!(!layer.is_tagged_response(data));
514        assert!(!layer.is_continuation(data));
515        assert_eq!(layer.tag(data).unwrap(), "*");
516        assert_eq!(layer.command(data).unwrap(), "OK");
517        assert_eq!(layer.args(data).unwrap(), "IMAP4rev1 Service Ready");
518    }
519
520    #[test]
521    fn test_imap_untagged_exists() {
522        let data = b"* 3 EXISTS\r\n";
523        let layer = make_layer(data);
524        assert!(layer.is_untagged(data));
525        assert_eq!(layer.command(data).unwrap(), "3");
526        assert_eq!(layer.args(data).unwrap(), "EXISTS");
527    }
528
529    #[test]
530    fn test_imap_tagged_ok_response() {
531        let data = b"A001 OK LOGIN completed\r\n";
532        let layer = make_layer(data);
533        assert!(layer.is_tagged_response(data));
534        assert_eq!(layer.tag(data).unwrap(), "A001");
535        assert_eq!(layer.command(data).unwrap(), "OK");
536        assert_eq!(layer.status(data).unwrap(), "OK");
537        assert_eq!(layer.args(data).unwrap(), "LOGIN completed");
538    }
539
540    #[test]
541    fn test_imap_tagged_no_response() {
542        let data = b"A002 NO login failed: wrong password\r\n";
543        let layer = make_layer(data);
544        assert!(layer.is_tagged_response(data));
545        assert_eq!(layer.tag(data).unwrap(), "A002");
546        assert_eq!(layer.status(data).unwrap(), "NO");
547    }
548
549    #[test]
550    fn test_imap_client_login_command() {
551        let data = b"A001 LOGIN alice password123\r\n";
552        let layer = make_layer(data);
553        assert!(layer.is_client_command(data));
554        assert_eq!(layer.tag(data).unwrap(), "A001");
555        assert_eq!(layer.command(data).unwrap(), "LOGIN");
556        assert_eq!(layer.args(data).unwrap(), "alice password123");
557    }
558
559    #[test]
560    fn test_imap_client_select() {
561        let data = b"A002 SELECT INBOX\r\n";
562        let layer = make_layer(data);
563        assert!(layer.is_client_command(data));
564        assert_eq!(layer.command(data).unwrap(), "SELECT");
565        assert_eq!(layer.args(data).unwrap(), "INBOX");
566    }
567
568    #[test]
569    fn test_imap_client_fetch() {
570        let data = b"A003 FETCH 1:* (FLAGS BODY[HEADER])\r\n";
571        let layer = make_layer(data);
572        assert!(layer.is_client_command(data));
573        assert_eq!(layer.command(data).unwrap(), "FETCH");
574    }
575
576    #[test]
577    fn test_imap_continuation() {
578        let data = b"+ dXNlcm5hbWU=\r\n";
579        let layer = make_layer(data);
580        assert!(layer.is_continuation(data));
581        assert_eq!(layer.tag(data).unwrap(), "+");
582    }
583
584    #[test]
585    fn test_imap_field_access() {
586        let data = b"A001 OK LOGIN completed\r\n";
587        let layer = make_layer(data);
588        assert!(matches!(
589            layer.get_field(data, "tag"),
590            Some(Ok(FieldValue::Str(ref t))) if t == "A001"
591        ));
592        assert!(matches!(
593            layer.get_field(data, "is_untagged"),
594            Some(Ok(FieldValue::Bool(false)))
595        ));
596        assert!(matches!(
597            layer.get_field(data, "is_tagged_response"),
598            Some(Ok(FieldValue::Bool(true)))
599        ));
600        assert!(layer.get_field(data, "bad_field").is_none());
601    }
602}