1#![deny(missing_docs)]
38#![deny(rustdoc::broken_intra_doc_links)]
39
40pub const FLAG_SEEN: u32 = 0b0000_0001;
42pub const FLAG_ANSWERED: u32 = 0b0000_0010;
44pub const FLAG_FLAGGED: u32 = 0b0000_0100;
46pub const FLAG_DELETED: u32 = 0b0000_1000;
48pub const FLAG_DRAFT: u32 = 0b0001_0000;
50pub const FLAG_RECENT: u32 = 0b0010_0000;
52
53pub fn format_imap_flags(flags: u32) -> String {
55 let mut parts = Vec::new();
56 if flags & FLAG_SEEN != 0 {
57 parts.push("\\Seen");
58 }
59 if flags & FLAG_ANSWERED != 0 {
60 parts.push("\\Answered");
61 }
62 if flags & FLAG_FLAGGED != 0 {
63 parts.push("\\Flagged");
64 }
65 if flags & FLAG_DELETED != 0 {
66 parts.push("\\Deleted");
67 }
68 if flags & FLAG_DRAFT != 0 {
69 parts.push("\\Draft");
70 }
71 if flags & FLAG_RECENT != 0 {
72 parts.push("\\Recent");
73 }
74 parts.join(" ")
75}
76
77pub fn parse_imap_flags(s: &str) -> u32 {
79 let s = s.trim().trim_start_matches('(').trim_end_matches(')');
80 let mut bits = 0u32;
81 for part in s.split_whitespace() {
82 let flag = part.trim_start_matches('\\');
83 match flag.to_uppercase().as_str() {
84 "SEEN" => bits |= FLAG_SEEN,
85 "ANSWERED" => bits |= FLAG_ANSWERED,
86 "FLAGGED" => bits |= FLAG_FLAGGED,
87 "DELETED" => bits |= FLAG_DELETED,
88 "DRAFT" => bits |= FLAG_DRAFT,
89 "RECENT" => bits |= FLAG_RECENT,
90 _ => {}
91 }
92 }
93 bits
94}
95
96pub fn format_internal_date(timestamp: i64) -> String {
100 use chrono::DateTime;
101 let dt = DateTime::from_timestamp(timestamp, 0).unwrap_or_default();
102 dt.format("%d-%b-%Y %H:%M:%S %z").to_string()
103}
104
105pub fn escape_imap_string(s: &str) -> String {
109 s.replace('\\', "\\\\").replace('"', "\\\"")
110}
111
112pub fn quote_or_nil(s: &str) -> String {
114 if s.is_empty() {
115 "NIL".to_string()
116 } else {
117 format!("\"{}\"", escape_imap_string(s))
118 }
119}
120
121pub fn format_imap_address(addr: &str) -> String {
124 let addr = addr.trim();
125 if addr.is_empty() {
126 return "NIL".to_string();
127 }
128
129 if let Some(lt) = addr.find('<') {
131 let name = addr[..lt].trim().trim_matches('"');
132 let email = addr[lt + 1..].trim_end_matches('>');
133 let (mailbox, host) = email.split_once('@').unwrap_or((email, ""));
134 let name_part = if name.is_empty() {
135 "NIL".to_string()
136 } else {
137 format!("\"{}\"", escape_imap_string(name))
138 };
139 return format!(
140 "(({name_part} NIL \"{}\" \"{}\"))",
141 escape_imap_string(mailbox),
142 escape_imap_string(host)
143 );
144 }
145
146 if let Some((mailbox, host)) = addr.split_once('@') {
148 format!(
149 "((NIL NIL \"{}\" \"{}\"))",
150 escape_imap_string(mailbox),
151 escape_imap_string(host)
152 )
153 } else {
154 format!("((NIL NIL \"{}\" \"\"))", escape_imap_string(addr))
155 }
156}
157
158pub fn parse_header_fields_request(attributes: &str) -> Option<(Vec<String>, String)> {
161 let upper = attributes.to_uppercase();
162 let marker = "HEADER.FIELDS";
163 let pos = upper.find(marker)?;
164 let after = &attributes[pos + marker.len()..];
165 let paren_start = after.find('(')?;
166 let paren_end = after.find(')')?;
167 let fields_str = &after[paren_start + 1..paren_end];
168 let fields: Vec<String> = fields_str
169 .split_whitespace()
170 .map(|s| s.to_uppercase())
171 .collect();
172 let raw_section = format!("HEADER.FIELDS ({})", fields_str.trim());
173 Some((fields, raw_section))
174}
175
176pub fn parse_generic_body_sections(attributes: &str) -> Vec<String> {
179 let upper = attributes.to_uppercase();
180 let mut sections = Vec::new();
181
182 for prefix in &["BODY.PEEK[", "BODY["] {
183 let mut search_from = 0;
184 while let Some(rel_pos) = upper[search_from..].find(prefix) {
185 let abs_start = search_from + rel_pos + prefix.len();
186 if let Some(end_rel) = upper[abs_start..].find(']') {
187 let section = attributes[abs_start..abs_start + end_rel].trim();
188 let sec_upper = section.to_uppercase();
189 if !section.is_empty()
190 && sec_upper != "HEADER"
191 && sec_upper != "TEXT"
192 && !sec_upper.contains("HEADER.FIELDS")
193 && section
194 .as_bytes()
195 .first()
196 .is_some_and(|b| b.is_ascii_digit())
197 {
198 let s = section.to_string();
199 if !sections.contains(&s) {
200 sections.push(s);
201 }
202 }
203 search_from = abs_start + end_rel + 1;
204 } else {
205 break;
206 }
207 }
208 }
209
210 sections
211}
212
213pub fn extract_header_fields(data: &[u8], fields: &[String]) -> Vec<u8> {
215 let header = extract_header_section(data);
216 let header_str = String::from_utf8_lossy(&header);
217 let mut result = Vec::new();
218 let mut include = false;
219 for line in header_str.lines() {
220 if line.is_empty() {
221 break;
222 }
223 if line.starts_with(' ') || line.starts_with('\t') {
224 if include {
225 result.extend_from_slice(line.as_bytes());
226 result.extend_from_slice(b"\r\n");
227 }
228 } else {
229 include = false;
230 if let Some(colon) = line.find(':') {
231 let name = line[..colon].trim().to_uppercase();
232 if fields.contains(&name) {
233 include = true;
234 result.extend_from_slice(line.as_bytes());
235 result.extend_from_slice(b"\r\n");
236 }
237 }
238 }
239 }
240 result.extend_from_slice(b"\r\n");
241 result
242}
243
244pub fn extract_header_section(data: &[u8]) -> Vec<u8> {
246 if let Some(pos) = data.windows(4).position(|w| w == b"\r\n\r\n") {
247 data[..pos + 4].to_vec()
248 } else if let Some(pos) = data.windows(2).position(|w| w == b"\n\n") {
249 data[..pos + 2].to_vec()
250 } else {
251 data.to_vec()
252 }
253}
254
255pub fn extract_body_section(data: &[u8]) -> Vec<u8> {
257 if let Some(pos) = data.windows(4).position(|w| w == b"\r\n\r\n") {
258 data[pos + 4..].to_vec()
259 } else if let Some(pos) = data.windows(2).position(|w| w == b"\n\n") {
260 data[pos + 2..].to_vec()
261 } else {
262 Vec::new()
263 }
264}
265
266pub struct MimeInfo {
270 pub media_type: String,
272 pub subtype: String,
274 pub charset: String,
276 pub encoding: String,
278 pub boundary: Option<String>,
280 pub name: Option<String>,
282 pub content_id: Option<String>,
284 pub disposition: Option<String>,
286 pub disposition_filename: Option<String>,
288}
289
290pub fn parse_mime_headers(header: &str) -> MimeInfo {
292 let mut media_type = "TEXT".to_string();
293 let mut subtype = "PLAIN".to_string();
294 let mut charset = "UTF-8".to_string();
295 let mut encoding = "7BIT".to_string();
296 let mut boundary = None;
297 let mut name = None;
298 let mut content_id = None;
299 let mut disposition = None;
300 let mut disposition_filename = None;
301
302 let mut unfolded = String::new();
304 for line in header.lines() {
305 if line.starts_with(' ') || line.starts_with('\t') {
306 unfolded.push(' ');
307 unfolded.push_str(line.trim());
308 } else {
309 if !unfolded.is_empty() {
310 unfolded.push('\n');
311 }
312 unfolded.push_str(line);
313 }
314 }
315
316 for line in unfolded.lines() {
317 let lower = line.to_lowercase();
318 if lower.starts_with("content-type:") {
319 let val = line["content-type:".len()..].trim();
320 let val_lower = val.to_lowercase();
321 if val_lower.starts_with("text/html") || val_lower.contains("text/html") {
322 media_type = "TEXT".to_string();
323 subtype = "HTML".to_string();
324 } else if val_lower.starts_with("text/plain") || val_lower.contains("text/plain") {
325 media_type = "TEXT".to_string();
326 subtype = "PLAIN".to_string();
327 } else if val_lower.contains("multipart/") {
328 media_type = "MULTIPART".to_string();
329 if val_lower.contains("multipart/mixed") {
330 subtype = "MIXED".to_string();
331 } else if val_lower.contains("multipart/alternative") {
332 subtype = "ALTERNATIVE".to_string();
333 } else if val_lower.contains("multipart/related") {
334 subtype = "RELATED".to_string();
335 } else {
336 subtype = "MIXED".to_string();
337 }
338 } else if val_lower.contains("application/") {
339 media_type = "APPLICATION".to_string();
340 if let Some(s) = val_lower.split('/').nth(1) {
341 subtype = s
342 .split(';')
343 .next()
344 .unwrap_or("OCTET-STREAM")
345 .trim()
346 .to_uppercase();
347 }
348 } else if val_lower.contains("image/") {
349 media_type = "IMAGE".to_string();
350 if let Some(s) = val_lower.split('/').nth(1) {
351 subtype = s.split(';').next().unwrap_or("JPEG").trim().to_uppercase();
352 }
353 }
354 if let Some(pos) = val_lower.find("charset=") {
356 let rest = &val[pos + 8..];
357 let cs = rest
358 .trim_start_matches('"')
359 .split(|c: char| c == '"' || c == ';' || c.is_whitespace())
360 .next()
361 .unwrap_or("UTF-8");
362 charset = cs.to_uppercase();
363 }
364 if let Some(pos) = val_lower.find("name=") {
366 let rest = &val[pos + 5..];
367 let n = if let Some(stripped) = rest.strip_prefix('"') {
368 stripped.split('"').next().unwrap_or("")
369 } else {
370 rest.split(|c: char| c == ';' || c.is_whitespace())
371 .next()
372 .unwrap_or("")
373 };
374 if !n.is_empty() {
375 name = Some(n.to_string());
376 }
377 }
378 if let Some(pos) = val_lower.find("boundary=") {
380 let rest = &val[pos + 9..];
381 let b = if let Some(stripped) = rest.strip_prefix('"') {
382 stripped.split('"').next().unwrap_or("")
383 } else {
384 rest.split(|c: char| c == ';' || c.is_whitespace())
385 .next()
386 .unwrap_or("")
387 };
388 boundary = Some(b.to_string());
389 }
390 }
391 if lower.starts_with("content-transfer-encoding:") {
392 let val = line["content-transfer-encoding:".len()..].trim();
393 encoding = match val.to_uppercase().as_str() {
394 "BASE64" => "BASE64".to_string(),
395 "QUOTED-PRINTABLE" => "QUOTED-PRINTABLE".to_string(),
396 "8BIT" => "8BIT".to_string(),
397 _ => "7BIT".to_string(),
398 };
399 }
400 if lower.starts_with("content-id:") {
401 let val = line["content-id:".len()..].trim();
402 content_id = Some(val.trim_matches(|c| c == '<' || c == '>').to_string());
403 }
404 if lower.starts_with("content-disposition:") {
405 let val = line["content-disposition:".len()..].trim();
406 let val_lower = val.to_lowercase();
407 if val_lower.starts_with("attachment") {
408 disposition = Some("attachment".to_string());
409 } else if val_lower.starts_with("inline") {
410 disposition = Some("inline".to_string());
411 }
412 if let Some(pos) = val_lower.find("filename=") {
413 let rest = &val[pos + 9..];
414 let f = if let Some(stripped) = rest.strip_prefix('"') {
415 stripped.split('"').next().unwrap_or("")
416 } else {
417 rest.split(|c: char| c == ';' || c.is_whitespace())
418 .next()
419 .unwrap_or("")
420 };
421 if !f.is_empty() {
422 disposition_filename = Some(f.to_string());
423 }
424 }
425 }
426 }
427
428 MimeInfo {
429 media_type,
430 subtype,
431 charset,
432 encoding,
433 boundary,
434 name,
435 content_id,
436 disposition,
437 disposition_filename,
438 }
439}
440
441pub fn split_mime_parts<'a>(body: &'a [u8], boundary: &str) -> Vec<&'a [u8]> {
443 let delim = format!("--{boundary}");
444 let body_str = String::from_utf8_lossy(body);
445 let mut parts = Vec::new();
446
447 let mut in_parts = false;
448 let mut part_start = 0;
449
450 for (i, line) in body_str.lines().enumerate() {
451 let trimmed = line.trim();
452 if trimmed.starts_with(&delim) {
453 if trimmed == format!("--{boundary}--")
454 || trimmed.starts_with(&format!("--{boundary}--"))
455 {
456 if in_parts
457 && let Some(pos) = find_line_offset(body, i)
458 && pos > part_start {
459 parts.push(&body[part_start..pos]);
460 }
461 break;
462 }
463 if in_parts
464 && let Some(pos) = find_line_offset(body, i)
465 && pos > part_start {
466 parts.push(&body[part_start..pos]);
467 }
468 if let Some(pos) = find_line_offset(body, i) {
469 let after = pos + line.len();
470 part_start =
471 if body.get(after) == Some(&b'\r') && body.get(after + 1) == Some(&b'\n') {
472 after + 2
473 } else if body.get(after) == Some(&b'\n') {
474 after + 1
475 } else {
476 after
477 };
478 }
479 in_parts = true;
480 }
481 }
482
483 parts
484}
485
486pub fn find_line_offset(body: &[u8], target_line: usize) -> Option<usize> {
488 let mut line_num = 0;
489 let mut pos = 0;
490 while pos < body.len() {
491 if line_num == target_line {
492 return Some(pos);
493 }
494 if let Some(nl) = body[pos..].iter().position(|&b| b == b'\n') {
495 pos = pos + nl + 1;
496 } else {
497 pos = body.len();
498 }
499 line_num += 1;
500 }
501 if line_num == target_line {
502 Some(pos)
503 } else {
504 None
505 }
506}
507
508pub fn trim_part_trailing_newline(data: &[u8]) -> &[u8] {
510 let mut end = data.len();
511 if end >= 2 && data[end - 2] == b'\r' && data[end - 1] == b'\n' {
512 end -= 2;
513 } else if end >= 1 && data[end - 1] == b'\n' {
514 end -= 1;
515 }
516 &data[..end]
517}
518
519fn build_part_bodystructure(part_data: &[u8]) -> String {
521 let header = extract_header_section(part_data);
522 let header_str = String::from_utf8_lossy(&header);
523 let body = extract_body_section(part_data);
524 let info = parse_mime_headers(&header_str);
525
526 if info.media_type == "MULTIPART" {
527 if let Some(ref boundary) = info.boundary {
528 let parts = split_mime_parts(&body, boundary);
529 let parts_str: String = parts.iter().map(|p| build_part_bodystructure(p)).collect();
530 return format!(
531 "({} \"{}\" (\"boundary\" \"{}\") NIL NIL)",
532 parts_str,
533 info.subtype.to_lowercase(),
534 boundary,
535 );
536 }
537 let body_trimmed = trim_part_trailing_newline(&body);
538 let body_lines = body_trimmed.split(|&b| b == b'\n').count();
539 return format!(
540 "(\"text\" \"plain\" (\"charset\" \"UTF-8\") NIL NIL \"7bit\" {} {} NIL NIL NIL)",
541 body_trimmed.len(),
542 body_lines,
543 );
544 }
545
546 let body_trimmed = trim_part_trailing_newline(&body);
547
548 let params = {
549 let mut pairs = Vec::new();
550 if info.media_type == "TEXT" {
551 pairs.push(format!("\"charset\" \"{}\"", info.charset));
552 }
553 if let Some(ref n) = info.name {
554 pairs.push(format!("\"name\" \"{}\"", escape_imap_str(n)));
555 }
556 if pairs.is_empty() {
557 "NIL".to_string()
558 } else {
559 format!("({})", pairs.join(" "))
560 }
561 };
562
563 let cid = info
564 .content_id
565 .as_ref()
566 .map(|id| format!("\"<{}>\"", id))
567 .unwrap_or_else(|| "NIL".to_string());
568
569 let dsp = if let Some(ref disp) = info.disposition {
570 if let Some(fname) = info.disposition_filename.as_ref().or(info.name.as_ref()) {
571 format!(
572 "(\"{}\" (\"filename\" \"{}\"))",
573 disp,
574 escape_imap_str(fname)
575 )
576 } else {
577 format!("(\"{}\" NIL)", disp)
578 }
579 } else {
580 "NIL".to_string()
581 };
582
583 if info.media_type == "TEXT" {
584 let body_lines = body_trimmed.split(|&b| b == b'\n').count();
585 format!(
586 "(\"text\" \"{}\" {} {} NIL \"{}\" {} {} NIL {} NIL)",
587 info.subtype.to_lowercase(),
588 params,
589 cid,
590 info.encoding.to_lowercase(),
591 body_trimmed.len(),
592 body_lines,
593 dsp,
594 )
595 } else {
596 format!(
597 "(\"{}\" \"{}\" {} {} NIL \"{}\" {} NIL {} NIL)",
598 info.media_type.to_lowercase(),
599 info.subtype.to_lowercase(),
600 params,
601 cid,
602 info.encoding.to_lowercase(),
603 body_trimmed.len(),
604 dsp,
605 )
606 }
607}
608
609pub fn escape_imap_str(s: &str) -> String {
611 s.replace('\\', "\\\\").replace('"', "\\\"")
612}
613
614pub fn build_bodystructure(data: &[u8]) -> String {
616 let header_bytes = extract_header_section(data);
617 let header = String::from_utf8_lossy(&header_bytes);
618 let body = extract_body_section(data);
619 let info = parse_mime_headers(&header);
620
621 if info.media_type == "MULTIPART"
622 && let Some(ref boundary) = info.boundary {
623 let parts = split_mime_parts(&body, boundary);
624 if !parts.is_empty() {
625 let parts_str: String = parts.iter().map(|p| build_part_bodystructure(p)).collect();
626 return format!(
627 "({} \"{}\" (\"boundary\" \"{}\") NIL NIL)",
628 parts_str,
629 info.subtype.to_lowercase(),
630 boundary,
631 );
632 }
633 }
634
635 let params = {
636 let mut pairs = Vec::new();
637 if info.media_type == "TEXT" {
638 pairs.push(format!("\"charset\" \"{}\"", info.charset));
639 }
640 if let Some(ref n) = info.name {
641 pairs.push(format!("\"name\" \"{}\"", escape_imap_str(n)));
642 }
643 if pairs.is_empty() {
644 "NIL".to_string()
645 } else {
646 format!("({})", pairs.join(" "))
647 }
648 };
649 let cid = info
650 .content_id
651 .as_ref()
652 .map(|id| format!("\"<{}>\"", id))
653 .unwrap_or_else(|| "NIL".to_string());
654 let dsp = if let Some(ref disp) = info.disposition {
655 if let Some(fname) = info.disposition_filename.as_ref().or(info.name.as_ref()) {
656 format!(
657 "(\"{}\" (\"filename\" \"{}\"))",
658 disp,
659 escape_imap_str(fname)
660 )
661 } else {
662 format!("(\"{}\" NIL)", disp)
663 }
664 } else {
665 "NIL".to_string()
666 };
667
668 if info.media_type == "TEXT" {
669 let body_lines = body.split(|&b| b == b'\n').count();
670 format!(
671 "(\"text\" \"{}\" {} {} NIL \"{}\" {} {} NIL {} NIL)",
672 info.subtype.to_lowercase(),
673 params,
674 cid,
675 info.encoding.to_lowercase(),
676 body.len(),
677 body_lines,
678 dsp,
679 )
680 } else {
681 format!(
682 "(\"{}\" \"{}\" {} {} NIL \"{}\" {} NIL {} NIL)",
683 info.media_type.to_lowercase(),
684 info.subtype.to_lowercase(),
685 params,
686 cid,
687 info.encoding.to_lowercase(),
688 body.len(),
689 dsp,
690 )
691 }
692}
693
694pub fn extract_mime_part(data: &[u8], section: &str) -> Option<Vec<u8>> {
696 let upper = section.to_uppercase();
697 if upper.ends_with(".MIME") {
698 let base = §ion[..section.len() - 5];
699 let part_raw = find_mime_part_raw(data, base)?;
700 return Some(extract_header_section(&part_raw));
701 }
702
703 find_mime_part_body(data, section)
704}
705
706fn find_mime_part_raw(data: &[u8], section: &str) -> Option<Vec<u8>> {
708 let header_bytes = extract_header_section(data);
709 let header = String::from_utf8_lossy(&header_bytes);
710 let body = extract_body_section(data);
711 let info = parse_mime_headers(&header);
712
713 if info.media_type != "MULTIPART" || info.boundary.is_none() {
714 if section == "1" {
715 return Some(data.to_vec());
716 }
717 return None;
718 }
719
720 let boundary = info.boundary.as_ref()?;
721 let parts = split_mime_parts(&body, boundary);
722
723 let mut parts_iter = section.split('.');
724 let first: usize = parts_iter.next()?.parse().ok()?;
725 let rest: String = parts_iter.collect::<Vec<_>>().join(".");
726
727 if first == 0 || first > parts.len() {
728 return None;
729 }
730
731 let part = parts[first - 1];
732 if rest.is_empty() {
733 Some(part.to_vec())
734 } else {
735 find_mime_part_raw(part, &rest)
736 }
737}
738
739fn find_mime_part_body(data: &[u8], section: &str) -> Option<Vec<u8>> {
741 let header_bytes = extract_header_section(data);
742 let header = String::from_utf8_lossy(&header_bytes);
743 let body = extract_body_section(data);
744 let info = parse_mime_headers(&header);
745
746 if info.media_type != "MULTIPART" || info.boundary.is_none() {
747 if section == "1" {
748 return Some(body);
749 }
750 return None;
751 }
752
753 let boundary = info.boundary.as_ref()?;
754 let parts = split_mime_parts(&body, boundary);
755
756 let mut parts_iter = section.split('.');
757 let first: usize = parts_iter.next()?.parse().ok()?;
758 let rest: String = parts_iter.collect::<Vec<_>>().join(".");
759
760 if first == 0 || first > parts.len() {
761 return None;
762 }
763
764 let part = parts[first - 1];
765 if rest.is_empty() {
766 Some(extract_body_section(part))
767 } else {
768 find_mime_part_body(part, &rest)
769 }
770}
771
772pub fn format_addr_list(addrs: &str) -> String {
774 let addrs = addrs.trim();
775 if addrs.is_empty() {
776 return "NIL".to_string();
777 }
778 let parts: Vec<String> = addrs
779 .split(',')
780 .map(|a| {
781 let a = a.trim();
782 if a.is_empty() {
783 return String::new();
784 }
785 let formatted = format_imap_address(a);
786 if formatted == "NIL" {
787 return String::new();
788 }
789 formatted[1..formatted.len() - 1].to_string()
790 })
791 .filter(|s| !s.is_empty())
792 .collect();
793 if parts.is_empty() {
794 "NIL".to_string()
795 } else {
796 format!("({})", parts.join(""))
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803
804 #[test]
805 fn format_flags_all() {
806 let flags = FLAG_SEEN | FLAG_ANSWERED | FLAG_FLAGGED | FLAG_DELETED | FLAG_DRAFT | FLAG_RECENT;
807 let s = format_imap_flags(flags);
808 assert!(s.contains("\\Seen"));
809 assert!(s.contains("\\Answered"));
810 assert!(s.contains("\\Flagged"));
811 assert!(s.contains("\\Deleted"));
812 assert!(s.contains("\\Draft"));
813 assert!(s.contains("\\Recent"));
814 }
815
816 #[test]
817 fn format_flags_empty() {
818 assert_eq!(format_imap_flags(0), "");
819 }
820
821 #[test]
822 fn parse_flags_round_trip() {
823 let bits = FLAG_SEEN | FLAG_FLAGGED;
824 let s = format_imap_flags(bits);
825 let parsed = parse_imap_flags(&format!("({})", s));
826 assert_eq!(parsed, bits);
827 }
828
829 #[test]
830 fn parse_flags_case_insensitive() {
831 let bits = parse_imap_flags("(\\seen \\FLAGGED \\Draft)");
832 assert_eq!(bits, FLAG_SEEN | FLAG_FLAGGED | FLAG_DRAFT);
833 }
834
835 #[test]
836 fn quote_or_nil_empty() {
837 assert_eq!(quote_or_nil(""), "NIL");
838 }
839
840 #[test]
841 fn quote_or_nil_value() {
842 assert_eq!(quote_or_nil("hello"), "\"hello\"");
843 }
844
845 #[test]
846 fn quote_or_nil_escapes() {
847 let result = quote_or_nil("he said \"hi\"");
848 assert!(result.contains("\\\""));
849 }
850
851 #[test]
852 fn format_imap_address_plain() {
853 let result = format_imap_address("user@example.com");
854 assert!(result.contains("\"user\""));
855 assert!(result.contains("\"example.com\""));
856 }
857
858 #[test]
859 fn format_imap_address_with_name() {
860 let result = format_imap_address("John Doe <john@example.com>");
861 assert!(result.contains("\"John Doe\""));
862 assert!(result.contains("\"john\""));
863 assert!(result.contains("\"example.com\""));
864 }
865
866 #[test]
867 fn format_imap_address_empty() {
868 assert_eq!(format_imap_address(""), "NIL");
869 }
870
871 #[test]
872 fn extract_header_section_crlf() {
873 let msg = b"From: a@b\r\nSubject: hi\r\n\r\nBody here";
874 let header = extract_header_section(msg);
875 assert!(header.ends_with(b"\r\n\r\n"));
876 assert!(!header.windows(4).any(|w| w == b"Body"));
877 }
878
879 #[test]
880 fn extract_body_section_crlf() {
881 let msg = b"From: a@b\r\n\r\nBody here";
882 let body = extract_body_section(msg);
883 assert_eq!(body, b"Body here");
884 }
885
886 #[test]
887 fn extract_header_fields_filters() {
888 let msg = b"From: a@b\r\nTo: c@d\r\nSubject: hi\r\n\r\nBody";
889 let fields = vec!["FROM".to_string()];
890 let result = extract_header_fields(msg, &fields);
891 let s = String::from_utf8_lossy(&result);
892 assert!(s.contains("From: a@b"));
893 assert!(!s.contains("To:"));
894 assert!(!s.contains("Subject:"));
895 }
896
897 #[test]
898 fn parse_mime_headers_text_plain() {
899 let info = parse_mime_headers("Content-Type: text/plain; charset=ISO-8859-1\r\n");
900 assert_eq!(info.media_type, "TEXT");
901 assert_eq!(info.subtype, "PLAIN");
902 assert_eq!(info.charset, "ISO-8859-1");
903 }
904
905 #[test]
906 fn parse_mime_headers_multipart() {
907 let info = parse_mime_headers("Content-Type: multipart/mixed; boundary=\"abc123\"\r\n");
908 assert_eq!(info.media_type, "MULTIPART");
909 assert_eq!(info.subtype, "MIXED");
910 assert_eq!(info.boundary, Some("abc123".to_string()));
911 }
912
913 #[test]
914 fn parse_mime_headers_attachment() {
915 let h = "Content-Type: application/pdf; name=\"doc.pdf\"\r\nContent-Disposition: attachment; filename=\"doc.pdf\"\r\n";
916 let info = parse_mime_headers(h);
917 assert_eq!(info.media_type, "APPLICATION");
918 assert_eq!(info.name, Some("doc.pdf".to_string()));
919 assert_eq!(info.disposition, Some("attachment".to_string()));
920 assert_eq!(info.disposition_filename, Some("doc.pdf".to_string()));
921 }
922
923 #[test]
924 fn build_bodystructure_simple_text() {
925 let msg = b"Content-Type: text/plain; charset=UTF-8\r\n\r\nHello world";
926 let bs = build_bodystructure(msg);
927 assert!(bs.contains("\"text\""));
928 assert!(bs.contains("\"plain\""));
929 assert!(bs.contains("\"charset\" \"UTF-8\""));
930 }
931
932 #[test]
933 fn format_addr_list_single() {
934 let result = format_addr_list("user@example.com");
935 assert!(result.contains("\"user\""));
936 assert!(result.contains("\"example.com\""));
937 }
938
939 #[test]
940 fn format_addr_list_multiple() {
941 let result = format_addr_list("a@b.com, c@d.com");
942 assert!(result.contains("\"a\""));
943 assert!(result.contains("\"c\""));
944 }
945
946 #[test]
947 fn format_addr_list_empty() {
948 assert_eq!(format_addr_list(""), "NIL");
949 }
950
951 #[test]
952 fn parse_header_fields_request_basic() {
953 let (fields, _) = parse_header_fields_request("BODY[HEADER.FIELDS (FROM TO SUBJECT)]").unwrap();
954 assert_eq!(fields, vec!["FROM", "TO", "SUBJECT"]);
955 }
956
957 #[test]
958 fn parse_generic_body_sections_basic() {
959 let sections = parse_generic_body_sections("BODY[1] BODY.PEEK[2]");
960 assert!(sections.contains(&"1".to_string()));
961 assert!(sections.contains(&"2".to_string()));
962 }
963
964 #[test]
965 fn parse_generic_body_sections_ignores_header() {
966 let sections = parse_generic_body_sections("BODY[HEADER]");
967 assert!(sections.is_empty());
968 }
969
970 #[test]
971 fn format_internal_date_epoch() {
972 let result = format_internal_date(0);
973 assert!(result.contains("1970"));
974 }
975
976 #[test]
977 fn extract_mime_part_single() {
978 let msg = b"Content-Type: text/plain\r\n\r\nHello";
979 let part = extract_mime_part(msg, "1").unwrap();
980 assert_eq!(part, b"Hello");
981 }
982
983 #[test]
984 fn extract_mime_part_out_of_range() {
985 let msg = b"Content-Type: text/plain\r\n\r\nHello";
986 assert!(extract_mime_part(msg, "2").is_none());
987 }
988}