stackforge_core/layer/imap/
mod.rs1pub mod builder;
78pub use builder::ImapBuilder;
79
80use crate::layer::field::{FieldError, FieldValue};
81use crate::layer::{Layer, LayerIndex, LayerKind};
82
83pub const IMAP_MIN_HEADER_LEN: usize = 4;
85
86pub const IMAP_PORT: u16 = 143;
88
89pub const IMAPS_PORT: u16 = 993;
91
92pub 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
149pub 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
156pub 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#[must_use]
176pub fn is_imap_payload(buf: &[u8]) -> bool {
177 if buf.len() < 3 {
178 return false;
179 }
180 if buf.starts_with(b"* ") {
182 return true;
183 }
184 if buf.starts_with(b"+ ") || buf == b"+\r\n" || buf == b"+ \r\n" {
186 return true;
187 }
188 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 if !tag.is_empty() && tag.chars().all(|c| c.is_ascii_alphanumeric()) {
197 if matches!(
199 cmd_or_status.as_str(),
200 "OK" | "NO" | "BAD" | "BYE" | "PREAUTH"
201 ) {
202 return true;
203 }
204 if IMAP_COMMANDS.contains(&cmd_or_status.as_str()) {
206 return true;
207 }
208 }
209 }
210 }
211 false
212}
213
214#[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 #[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 #[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 #[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 #[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 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 pub fn command(&self, buf: &[u8]) -> Result<String, FieldError> {
319 let line = self.first_line(buf);
320 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 if line.starts_with("+ ") {
327 return Ok("+".to_string());
328 }
329 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 pub fn args(&self, buf: &[u8]) -> Result<String, FieldError> {
346 let line = self.first_line(buf);
347 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 if let Some(rest) = line.strip_prefix("+ ") {
354 return Ok(rest.trim().to_string());
355 }
356 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 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 pub fn text(&self, buf: &[u8]) -> Result<String, FieldError> {
387 let args = self.args(buf)?;
388 Ok(args)
389 }
390
391 #[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#[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")); }
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}