Skip to main content

mailrs_imap_format/
lib.rs

1//! IMAP wire-format helpers (RFC 9051 §6.4 FETCH responses, §7.5
2//! BODYSTRUCTURE assembly, §9 ABNF for FLAGS / INTERNALDATE).
3//!
4//! Pairs with [`mailrs-imap-proto`](https://crates.io/crates/mailrs-imap-proto)
5//! (command parsing + session state machine) and
6//! [`mailrs-imap-codec`](https://crates.io/crates/mailrs-imap-codec)
7//! (line / literal framing) to form a complete RFC 9051 receive +
8//! response stack.
9//!
10//! 22 standalone helpers grouped by concern:
11//!
12//! - **FLAGS** — `format_imap_flags` / `parse_imap_flags` map the 6
13//!   standard system flags between `u32` bitmask and IMAP
14//!   `(\Seen \Flagged …)` syntax. Bit assignments are exposed via
15//!   the `FLAG_*` `pub const` so callers can construct masks
16//!   without round-tripping through strings.
17//! - **INTERNALDATE** — `format_internal_date(i64)` formats a Unix
18//!   timestamp as IMAP's `"DD-Mon-YYYY HH:MM:SS +ZZZZ"`.
19//! - **String quoting** — `escape_imap_string` / `escape_imap_str`
20//!   / `quote_or_nil` handle IMAP's `"…"` quoted-string with
21//!   `\`-escapes-or-`NIL` rules.
22//! - **Address parsing** — `format_imap_address` + `format_addr_list`
23//!   turn RFC 5322 addresses (with optional display name) into
24//!   IMAP's `((name route mailbox host))` structure.
25//! - **BODY[] section requests** — `parse_header_fields_request`
26//!   (`BODY[HEADER.FIELDS (…)]`) + `parse_generic_body_sections`
27//!   (`BODY[1]`, `BODY[1.MIME]`, etc.).
28//! - **MIME walk** — `extract_header_section` / `extract_body_section`
29//!   / `extract_header_fields` / `parse_mime_headers` (returns
30//!   [`MimeInfo`]) / `split_mime_parts` / `find_line_offset` /
31//!   `trim_part_trailing_newline` / `extract_mime_part`.
32//! - **BODYSTRUCTURE** — `build_bodystructure` recurses through
33//!   multipart trees and emits the RFC-9051 §7.5.2 form.
34//!
35//! All helpers are pure functions — no I/O, no async.
36
37#![deny(missing_docs)]
38#![deny(rustdoc::broken_intra_doc_links)]
39
40/// IMAP `\Seen` system flag. Bit-0 of the standard u32 mask.
41pub const FLAG_SEEN: u32 = 0b0000_0001;
42/// IMAP `\Answered` system flag. Bit-1.
43pub const FLAG_ANSWERED: u32 = 0b0000_0010;
44/// IMAP `\Flagged` system flag (star). Bit-2.
45pub const FLAG_FLAGGED: u32 = 0b0000_0100;
46/// IMAP `\Deleted` system flag (queued for expunge). Bit-3.
47pub const FLAG_DELETED: u32 = 0b0000_1000;
48/// IMAP `\Draft` system flag. Bit-4.
49pub const FLAG_DRAFT: u32 = 0b0001_0000;
50/// IMAP `\Recent` system flag (set by EXAMINE/SELECT). Bit-5.
51pub const FLAG_RECENT: u32 = 0b0010_0000;
52
53/// convert bitmask flags to IMAP flag string
54pub 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
77/// parse IMAP flag names from a FLAGS string like "(\\Seen \\Flagged)"
78pub 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
96/// Format a Unix timestamp as an IMAP `INTERNALDATE` value
97/// (`"DD-Mon-YYYY HH:MM:SS +ZZZZ"`, RFC 9051 §9 ABNF
98/// `date-time`).
99pub 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
105/// Escape `\` and `"` characters for embedding inside an IMAP
106/// `"…"` quoted string. Does NOT add the surrounding quotes —
107/// see [`quote_or_nil`] for the full quoted-or-`NIL` decision.
108pub fn escape_imap_string(s: &str) -> String {
109    s.replace('\\', "\\\\").replace('"', "\\\"")
110}
111
112/// quote a string for IMAP or return NIL if empty
113pub 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
121/// parse an email address "Name <user@host>" or "user@host" into IMAP address structure
122/// returns ((name NIL mailbox host)) or NIL if empty
123pub 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    // parse "Name <user@host>" format
130    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    // plain "user@host"
147    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
158/// parse BODY[HEADER.FIELDS (field-list)] or BODY.PEEK[HEADER.FIELDS (field-list)]
159/// returns (field_names, raw_section_text)
160pub 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
176/// parse all generic BODY[section] requests like BODY[1], BODY[1.1], BODY[1.MIME], BODY.PEEK[1]
177/// returns all section specifiers (e.g. ["1", "1.1", "1.MIME"])
178pub 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
213/// extract only the specified header fields from raw message
214pub 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
244/// extract header section from raw message (up to \r\n\r\n)
245pub 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
255/// extract body section from raw message (after \r\n\r\n)
256pub 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
266/// Parsed `Content-Type` + `Content-Transfer-Encoding` +
267/// `Content-Disposition` info extracted from a single MIME part's
268/// header block. Returned by [`parse_mime_headers`].
269pub struct MimeInfo {
270    /// `Content-Type` major type ("TEXT", "MULTIPART", "APPLICATION", ...). Uppercase.
271    pub media_type: String,
272    /// `Content-Type` subtype ("PLAIN", "HTML", "MIXED", "PDF", ...). Uppercase.
273    pub subtype: String,
274    /// `Content-Type` `charset=` parameter. Defaults to `"UTF-8"`.
275    pub charset: String,
276    /// `Content-Transfer-Encoding` ("7BIT", "BASE64", "QUOTED-PRINTABLE", "8BIT"). Defaults to `"7BIT"`.
277    pub encoding: String,
278    /// `Content-Type` `boundary=` parameter for multipart bodies. `None` for non-multipart.
279    pub boundary: Option<String>,
280    /// `Content-Type` `name=` parameter (legacy way to name an attachment).
281    pub name: Option<String>,
282    /// `Content-ID` value with `<…>` brackets stripped.
283    pub content_id: Option<String>,
284    /// `Content-Disposition` value: `"attachment"` or `"inline"`. `None` if absent.
285    pub disposition: Option<String>,
286    /// `Content-Disposition` `filename=` parameter — preferred over `name=` when present.
287    pub disposition_filename: Option<String>,
288}
289
290/// parse content-type and transfer-encoding from header text
291pub 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    // unfold headers (join continuation lines)
303    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            // extract charset
355            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            // extract name
365            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            // extract boundary
379            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
441/// split multipart body by boundary, returning each part as raw bytes (including part headers)
442pub 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
486/// find byte offset of line number in body
487pub 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
508/// trim trailing CRLF/LF from part body (boundary transport padding per RFC 2046)
509pub 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
519/// build a single part's BODYSTRUCTURE string (with extension data)
520fn 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
609/// escape double quotes in IMAP string literals
610pub fn escape_imap_str(s: &str) -> String {
611    s.replace('\\', "\\\\").replace('"', "\\\"")
612}
613
614/// build BODYSTRUCTURE for a message (handles multipart, with extension data)
615pub 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
694/// extract a specific MIME part by number (e.g. "1", "2", "1.1", "1.MIME")
695pub 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 = &section[..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
706/// find a MIME part's raw data (headers + body) by section number
707fn 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
739/// extract a specific MIME part's body by section number (e.g. "1", "2", "1.1")
740fn 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
772/// format a comma-separated list of addresses into IMAP address list
773pub 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}