Skip to main content

hypermail/
mime.rs

1use crate::error::Result;
2
3/// Parsed MIME Content-Type header with type, subtype, and parameters.
4#[derive(Debug, Clone)]
5pub struct ContentType {
6    pub type_: String,
7    pub subtype: String,
8    pub params: std::collections::HashMap<String, String>,
9}
10
11impl ContentType {
12    /// Parses a Content-Type header value into structured components.
13    pub fn parse(s: &str) -> Self {
14        let s = s.trim();
15        let mut params = std::collections::HashMap::new();
16
17        let (base, param_str) = if let Some(semi) = s.find(';') {
18            (s[..semi].trim(), Some(s[semi + 1..].trim()))
19        } else {
20            (s, None)
21        };
22
23        let (type_, subtype) = if let Some(slash) = base.find('/') {
24            (base[..slash].trim().to_lowercase(), base[slash + 1..].trim().to_lowercase())
25        } else {
26            (base.to_lowercase(), "".to_string())
27        };
28
29        if let Some(pstr) = param_str {
30            for part in pstr.split(';') {
31                let part = part.trim();
32                if let Some(eq) = part.find('=') {
33                    let key = part[..eq].trim().to_lowercase();
34                    let mut val = part[eq + 1..].trim().to_string();
35                    if (val.starts_with('"') && val.ends_with('"'))
36                        || (val.starts_with('\'') && val.ends_with('\''))
37                    {
38                        val = val[1..val.len() - 1].to_string();
39                    }
40                    params.insert(key, val);
41                }
42            }
43        }
44
45        ContentType { type_, subtype, params }
46    }
47
48    pub fn is_text(&self) -> bool {
49        self.type_ == "text"
50    }
51
52    pub fn is_multipart(&self) -> bool {
53        self.type_ == "multipart"
54    }
55
56    pub fn boundary(&self) -> Option<&str> {
57        self.params.get("boundary").map(|s| s.as_str())
58    }
59
60    pub fn charset(&self) -> Option<&str> {
61        self.params.get("charset").map(|s| s.as_str())
62    }
63
64    pub fn name(&self) -> Option<&str> {
65        self.params.get("name").map(|s| s.as_str())
66    }
67
68    pub fn full_type(&self) -> String {
69        format!("{}/{}", self.type_, self.subtype)
70    }
71}
72
73/// Parsed MIME Content-Disposition header with disposition type and parameters.
74#[derive(Debug, Clone)]
75pub struct ContentDisposition {
76    pub disposition: String,
77    pub params: std::collections::HashMap<String, String>,
78}
79
80impl ContentDisposition {
81    /// Parses a Content-Disposition header value into structured components.
82    pub fn parse(s: &str) -> Self {
83        let s = s.trim();
84        let mut params = std::collections::HashMap::new();
85
86        let (disp, param_str) = if let Some(semi) = s.find(';') {
87            (s[..semi].trim().to_lowercase(), Some(s[semi + 1..].trim()))
88        } else {
89            (s.to_lowercase(), None)
90        };
91
92        if let Some(pstr) = param_str {
93            for part in pstr.split(';') {
94                let part = part.trim();
95                if let Some(eq) = part.find('=') {
96                    let key = part[..eq].trim().to_lowercase();
97                    let mut val = part[eq + 1..].trim().to_string();
98                    if (val.starts_with('"') && val.ends_with('"'))
99                        || (val.starts_with('\'') && val.ends_with('\''))
100                    {
101                        val = val[1..val.len() - 1].to_string();
102                    }
103                    params.insert(key, val);
104                }
105            }
106        }
107
108        ContentDisposition { disposition: disp, params }
109    }
110
111    pub fn filename(&self) -> Option<&str> {
112        self.params.get("filename").map(|s| s.as_str())
113    }
114
115    pub fn is_attachment(&self) -> bool {
116        self.disposition == "attachment"
117    }
118}
119
120fn hex_val(b: u8) -> Option<u8> {
121    match b {
122        b'0'..=b'9' => Some(b - b'0'),
123        b'A'..=b'F' => Some(b - b'A' + 10),
124        b'a'..=b'f' => Some(b - b'a' + 10),
125        _ => None,
126    }
127}
128
129/// Decodes base64-encoded data, ignoring whitespace.
130pub fn decode_base64(data: &[u8]) -> Result<Vec<u8>> {
131    let text = std::str::from_utf8(data)
132        .map_err(|e| crate::error::HypermailError::Parse(format!("Invalid base64 text: {e}")))?;
133
134    let clean: String = text.chars().filter(|c| !c.is_whitespace()).collect();
135
136    use base64::Engine as _;
137    let engine = base64::engine::general_purpose::STANDARD;
138    engine
139        .decode(&clean)
140        .map_err(|e| crate::error::HypermailError::Parse(format!("Base64 decode error: {e}")))
141}
142
143/// Decodes quoted-printable encoded data, handling soft line breaks and `_` as space.
144pub fn decode_quoted_printable(data: &[u8]) -> Vec<u8> {
145    let mut result = Vec::with_capacity(data.len());
146    let mut i = 0;
147    while i < data.len() {
148        if data[i] == b'=' {
149            if i + 2 < data.len() && data[i + 1] == b'\r' && data[i + 2] == b'\n' {
150                // Soft line break: =\r\n
151                i += 3;
152                continue;
153            }
154            if i + 1 < data.len() && data[i + 1] == b'\n' {
155                // Soft line break: =\n (Unix-style, no \r)
156                i += 2;
157                continue;
158            }
159            if i + 2 < data.len() {
160                if let (Some(h), Some(l)) = (hex_val(data[i + 1]), hex_val(data[i + 2])) {
161                    result.push(h << 4 | l);
162                    i += 3;
163                    continue;
164                }
165            }
166        }
167        if data[i] == b'_' {
168            result.push(b' ');
169        } else if data[i] != b'\r' {
170            result.push(data[i]);
171        }
172        i += 1;
173    }
174    result
175}
176
177/// Decodes uuencoded data, returning `None` if no valid uuencode block is found.
178pub fn decode_uuencode(data: &[u8]) -> Option<Vec<u8>> {
179    let text = std::str::from_utf8(data).ok()?;
180    let mut result = Vec::new();
181    let mut in_encoded = false;
182
183    for line in text.lines() {
184        let line = line.trim_end();
185        if line.starts_with("begin ") {
186            in_encoded = true;
187            continue;
188        }
189        if line == "end" || line == "`" {
190            in_encoded = false;
191            continue;
192        }
193        if !in_encoded || line.is_empty() {
194            continue;
195        }
196
197        let bytes = line.as_bytes();
198        if bytes.is_empty() {
199            continue;
200        }
201
202        let count = (bytes[0] as usize - 32) & 0x3f;
203        if count == 0 {
204            continue;
205        }
206
207        let mut buf = [0u8; 3];
208        let mut j = 1;
209        let mut out = 0;
210
211        while j < bytes.len() && out < count {
212            let mut chars = [0u8; 4];
213            let mut n = 0;
214            while n < 4 && j < bytes.len() {
215                chars[n] = bytes[j].wrapping_sub(32) & 0x3f;
216                j += 1;
217                n += 1;
218            }
219
220            if n >= 2 {
221                buf[0] = (chars[0] << 2) | (chars[1] >> 4);
222            }
223            if n >= 3 {
224                buf[1] = (chars[1] << 4) | (chars[2] >> 2);
225            }
226            if n >= 4 {
227                buf[2] = (chars[2] << 6) | chars[3];
228            }
229
230            let to_push = n.saturating_sub(1);
231            result.extend_from_slice(&buf[..to_push]);
232            out += to_push;
233        }
234    }
235
236    if result.is_empty() {
237        None
238    } else {
239        Some(result)
240    }
241}
242
243/// Combined MIME content-type and transfer-encoding information for a message part.
244#[derive(Debug, Clone)]
245pub struct MimeInfo {
246    pub content_type: ContentType,
247    pub content_transfer_encoding: Option<String>,
248}
249
250/// Extracts MIME info (content-type and transfer-encoding) from parsed headers.
251pub fn parse_mime_info(headers: &[(String, String)]) -> Option<MimeInfo> {
252    let ct_str = headers
253        .iter()
254        .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
255        .map(|(_, val)| val.as_str())?;
256
257    let content_type = ContentType::parse(ct_str);
258    let cte = headers
259        .iter()
260        .find(|(name, _)| name.eq_ignore_ascii_case("content-transfer-encoding"))
261        .map(|(_, val)| val.trim().to_lowercase());
262
263    Some(MimeInfo { content_type, content_transfer_encoding: cte })
264}
265
266fn find_multipart_charset(body: &[u8], boundary: &str) -> Option<String> {
267    let boundary_tag = format!("--{}", boundary);
268    let boundary_bytes = boundary_tag.as_bytes();
269    let mut pos = 0;
270
271    while pos < body.len() {
272        // Find next boundary starting from pos
273        let start =
274            match body[pos..].windows(boundary_bytes.len()).position(|w| w == boundary_bytes) {
275                Some(offset) => pos + offset,
276                None => break, // No more boundaries found, exit loop
277            };
278
279        let after_boundary = &body[start + boundary_bytes.len()..];
280
281        // Verify boundary is followed by newline (not just part of content)
282        let after_eol = if after_boundary.starts_with(b"\r\n") {
283            &after_boundary[2..]
284        } else if after_boundary.starts_with(b"\n") {
285            &after_boundary[1..]
286        } else {
287            // Not a valid boundary, continue searching
288            pos = start + 1;
289            continue;
290        };
291
292        // Find end of headers (empty line)
293        let header_end = after_eol
294            .windows(2)
295            .position(|w| w == b"\n\n")
296            .or_else(|| after_eol.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 2));
297
298        if let Some(header_end) = header_end {
299            let part_headers = &after_eol[..header_end];
300            if let Ok(header_block) = std::str::from_utf8(part_headers) {
301                for line in header_block.lines() {
302                    let lower = line.to_lowercase();
303                    if lower.starts_with("content-type:") {
304                        if let Some(charset_start) = lower.find("charset=") {
305                            let after = &line[charset_start + 8..];
306                            let charset = after.trim().trim_matches('"').trim_matches('\'');
307                            let charset =
308                                charset.split([';', ' ', '\r', '\n']).next().unwrap_or(charset);
309                            if !charset.is_empty() {
310                                return Some(charset.to_string());
311                            }
312                        }
313                    }
314                }
315            }
316        }
317
318        // Move past this boundary to search for next part
319        pos = start + boundary_bytes.len();
320    }
321    None
322}
323
324/// Decodes a MIME message body using its content-type and transfer-encoding.
325///
326/// Handles charset conversion, multipart boundaries, and format=flowed unwrapping.
327pub fn decode_body(body: &[u8], mime_info: &MimeInfo) -> String {
328    let decoded_bytes = match mime_info.content_transfer_encoding.as_deref() {
329        Some("base64") => match decode_base64(body) {
330            Ok(bytes) => bytes,
331            Err(_) => body.to_vec(),
332        },
333        Some("quoted-printable") | Some("qp") => decode_quoted_printable(body),
334        // 7bit, 8bit, binary → use raw bytes
335        _ => body.to_vec(),
336    };
337
338    let charset: Option<String> =
339        mime_info.content_type.charset().map(|s| s.to_string()).or_else(|| {
340            if mime_info.content_type.is_multipart() {
341                if let Some(boundary) = mime_info.content_type.boundary() {
342                    find_multipart_charset(body, boundary)
343                } else {
344                    None
345                }
346            } else {
347                None
348            }
349        });
350
351    // Use smart charset detection that handles mislabeled charsets
352    if let Some(ref charset) = charset {
353        return crate::headers::decode_to_utf8(&decoded_bytes, charset);
354    }
355
356    // No charset specified: try UTF-8 first, then common fallbacks
357    if let Ok(s) = std::str::from_utf8(&decoded_bytes) {
358        return s.to_string();
359    }
360
361    // Try common Greek/European charsets as fallback
362    for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
363        if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
364            let (cow, _, _) = encoding.decode(&decoded_bytes);
365            if !cow.contains('\u{FFFD}') {
366                return cow.into_owned();
367            }
368        }
369    }
370
371    String::from_utf8_lossy(&decoded_bytes).to_string()
372}
373
374/// QUAL-4: Variant of `decode_body` that skips internal charset resolution,
375/// using the already-resolved `charset` string instead.
376fn decode_body_with_charset(body: &[u8], mime_info: &MimeInfo, charset: Option<&str>) -> String {
377    let decoded_bytes = match mime_info.content_transfer_encoding.as_deref() {
378        Some("base64") => match decode_base64(body) {
379            Ok(bytes) => bytes,
380            Err(_) => body.to_vec(),
381        },
382        Some("quoted-printable") | Some("qp") => decode_quoted_printable(body),
383        _ => body.to_vec(),
384    };
385
386    if let Some(cs) = charset {
387        return crate::headers::decode_to_utf8(&decoded_bytes, cs);
388    }
389
390    if let Ok(s) = std::str::from_utf8(&decoded_bytes) {
391        return s.to_string();
392    }
393
394    for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
395        if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
396            let (cow, _, _) = encoding.decode(&decoded_bytes);
397            if !cow.contains('\u{FFFD}') {
398                return cow.into_owned();
399            }
400        }
401    }
402
403    String::from_utf8_lossy(&decoded_bytes).to_string()
404}
405
406fn resolve_charset(body_raw: &[u8], mi: &MimeInfo) -> Option<String> {
407    mi.content_type.charset().map(|s| s.to_string()).or_else(|| {
408        if mi.content_type.is_multipart() {
409            if let Some(boundary) = mi.content_type.boundary() {
410                find_multipart_charset(body_raw, boundary)
411            } else {
412                None
413            }
414        } else {
415            None
416        }
417    })
418}
419
420/// Maximum nested multipart depth to prevent stack overflow from crafted messages.
421const MAX_MULTIPART_DEPTH: u32 = 16;
422
423/// Processes a MIME message body, returning decoded text and detected charset.
424///
425/// Handles multipart messages, inline images, attachments, and charset detection.
426///
427/// # Security
428///
429/// Only safe image MIME types are embedded inline; SVG is excluded due to script risks.
430/// Nested multiparts are limited to [`MAX_MULTIPART_DEPTH`] levels.
431pub fn process_mime_body(
432    headers: &[(String, String)],
433    body_raw: &[u8],
434) -> (String, Option<String>) {
435    process_mime_body_depth(headers, body_raw, 0)
436}
437
438fn process_mime_body_depth(
439    headers: &[(String, String)],
440    body_raw: &[u8],
441    depth: u32,
442) -> (String, Option<String>) {
443    let mi = parse_mime_info(headers);
444    if let Some(ref mi) = mi {
445        // Check if this is a multipart message
446        if mi.content_type.is_multipart() {
447            if depth >= MAX_MULTIPART_DEPTH {
448                log::warn!(
449                    "multipart nesting depth limit ({}) exceeded; treating as plain text",
450                    MAX_MULTIPART_DEPTH
451                );
452                return (String::from_utf8_lossy(body_raw).to_string(), None);
453            }
454            if let Some(boundary) = mi.content_type.boundary() {
455                return process_multipart_body(body_raw, boundary, mi, depth);
456            }
457        }
458
459        let charset = resolve_charset(body_raw, mi);
460        // QUAL-4: Use decode_body_with_charset to avoid resolving charset twice.
461        let mut decoded = decode_body_with_charset(body_raw, mi, charset.as_deref());
462        // RFC 3676: unwrap format=flowed text
463        if mi
464            .content_type
465            .params
466            .get("format")
467            .map(|v| v.eq_ignore_ascii_case("flowed"))
468            .unwrap_or(false)
469        {
470            decoded = unflow_text(&decoded);
471        }
472        (decoded, charset)
473    } else {
474        // No Content-Type header: try UTF-8 first, then fallback charsets
475        if let Ok(s) = std::str::from_utf8(body_raw) {
476            return (s.to_string(), None);
477        }
478        for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
479            if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
480                let (cow, _, _) = encoding.decode(body_raw);
481                if !cow.contains('\u{FFFD}') {
482                    return (cow.into_owned(), Some(label.to_string()));
483                }
484            }
485        }
486        (String::from_utf8_lossy(body_raw).to_string(), None)
487    }
488}
489
490fn process_multipart_body(
491    body: &[u8],
492    boundary: &str,
493    parent_mime: &MimeInfo,
494    depth: u32,
495) -> (String, Option<String>) {
496    let is_alternative = parent_mime.content_type.subtype == "alternative";
497    let boundary_tag = format!("--{}", boundary);
498    let boundary_bytes = boundary_tag.as_bytes();
499    let mut result = String::new();
500    let mut detected_charset = None;
501    let mut pos = 0;
502
503    // For multipart/alternative: collect all text parts, then pick the best one.
504    // Prefer text/plain over text/html to avoid rendering raw HTML.
505    let mut alt_plain: Option<(String, Option<String>)> = None;
506    let mut alt_html: Option<(String, Option<String>)> = None;
507
508    while pos < body.len() {
509        // Find next boundary
510        let start =
511            match body[pos..].windows(boundary_bytes.len()).position(|w| w == boundary_bytes) {
512                Some(offset) => pos + offset,
513                None => break,
514            };
515
516        // Check for end boundary
517        let after_boundary = &body[start + boundary_bytes.len()..];
518        if after_boundary.starts_with(b"--") {
519            // End boundary found
520            break;
521        }
522
523        // Skip to content after boundary line
524        let after_eol = if after_boundary.starts_with(b"\r\n") {
525            &after_boundary[2..]
526        } else if after_boundary.starts_with(b"\n") {
527            &after_boundary[1..]
528        } else {
529            pos = start + boundary_bytes.len();
530            continue;
531        };
532
533        // Find end of part headers
534        let header_end = after_eol
535            .windows(2)
536            .position(|w| w == b"\n\n")
537            .or_else(|| after_eol.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 2));
538
539        if let Some(header_end) = header_end {
540            let part_headers_bytes = &after_eol[..header_end];
541            let part_body_start = header_end + 2;
542
543            // Find next boundary to determine part body end
544            let part_body = if let Some(next_boundary_pos) = after_eol[part_body_start..]
545                .windows(boundary_bytes.len())
546                .position(|w| w == boundary_bytes)
547            {
548                &after_eol[part_body_start..part_body_start + next_boundary_pos]
549            } else {
550                &after_eol[part_body_start..]
551            };
552
553            // Parse part headers
554            if let Ok(headers_str) = std::str::from_utf8(part_headers_bytes) {
555                let mut part_headers = Vec::new();
556                for line in headers_str.lines() {
557                    if let Some((name, value)) = line.split_once(':') {
558                        part_headers.push((name.trim().to_lowercase(), value.trim().to_string()));
559                    }
560                }
561
562                // Check if this part is an attachment, inline content, or has Content-ID
563                let mut is_attachment = false;
564                let mut _has_content_id = false;
565                let mut content_type_main = String::new();
566                let mut encoding = String::new();
567
568                for (name, value) in &part_headers {
569                    if name == "content-disposition" {
570                        is_attachment = value.to_lowercase().starts_with("attachment");
571                    }
572                    if name == "content-id" {
573                        _has_content_id = true;
574                    }
575                    if name == "content-type" {
576                        if let Some(main_type) = value.split(';').next() {
577                            content_type_main = main_type.trim().to_lowercase();
578                        }
579                    }
580                    if name == "content-transfer-encoding" {
581                        encoding = value.trim().to_lowercase();
582                    }
583                }
584
585                // Allowlist of safe image MIME types for inline embedding.
586                // image/svg+xml is excluded — SVG can contain scripts.
587                const SAFE_IMAGE_TYPES: &[&str] = &[
588                    "image/gif",
589                    "image/jpeg",
590                    "image/jpg",
591                    "image/png",
592                    "image/webp",
593                    "image/bmp",
594                    "image/tiff",
595                ];
596
597                // Determine how to handle this part
598                if content_type_main.starts_with("image/")
599                    && SAFE_IMAGE_TYPES.contains(&content_type_main.as_str())
600                {
601                    // Always embed images inline for the HTML archive — viewers browse,
602                    // they don't download.  Content-Disposition: attachment is an email-client
603                    // hint that does not apply here.  Only fall back to a link if the raw
604                    // data is missing or decoding fails.
605                    let image_data = if encoding == "base64" {
606                        decode_base64(part_body.trim_ascii()).ok()
607                    } else if !part_body.is_empty() {
608                        Some(part_body.to_vec())
609                    } else {
610                        None
611                    };
612
613                    if let Some(data) = image_data {
614                        use base64::Engine as _;
615                        let engine = base64::engine::general_purpose::STANDARD;
616                        let base64_data = engine.encode(&data);
617                        if !result.is_empty() {
618                            result.push('\n');
619                        }
620                        result.push_str(&format!(
621                            "[INLINE_IMAGE:{}:{}]\n",
622                            content_type_main, base64_data
623                        ));
624                    } else if let Some(filename) = extract_filename(&part_headers) {
625                        // Decoding failed — fall back to a named attachment link
626                        if !result.is_empty() {
627                            result.push('\n');
628                        }
629                        result.push_str(&format!("[Attachment: {}]\n", filename));
630                    }
631                } else if content_type_main.starts_with("image/")
632                    || is_attachment
633                    || content_type_main.starts_with("application/")
634                {
635                    // Non-safe image or non-image attachment - just note it
636                    if let Some(filename) = extract_filename(&part_headers) {
637                        if !result.is_empty() {
638                            result.push('\n');
639                        }
640                        result.push_str(&format!("[Attachment: {}]\n", filename));
641                    }
642                } else if content_type_main.starts_with("text/")
643                    || content_type_main.starts_with("multipart/")
644                    || content_type_main.is_empty()
645                {
646                    // Process text / nested multipart content (depth-limited)
647                    let (decoded, charset) =
648                        process_mime_body_depth(&part_headers, part_body, depth + 1);
649                    if is_alternative {
650                        // LOG-2: For multipart/alternative, collect parts separately.
651                        if content_type_main == "text/plain" || content_type_main.is_empty() {
652                            if alt_plain.is_none() {
653                                alt_plain = Some((decoded, charset));
654                            }
655                        } else if content_type_main == "text/html" && alt_html.is_none() {
656                            alt_html = Some((decoded, charset));
657                        }
658                        // other text/* subtypes ignored for alternative
659                    } else {
660                        if detected_charset.is_none() && charset.is_some() {
661                            detected_charset = charset;
662                        }
663                        if !result.is_empty() && !decoded.is_empty() {
664                            result.push('\n');
665                        }
666                        result.push_str(&decoded);
667                    }
668                }
669            }
670        }
671
672        // Move to next part
673        pos = start + boundary_bytes.len();
674    }
675
676    // LOG-2: For multipart/alternative, select the single best part.
677    // Prefer text/plain; fall back to text/html if no plain part exists.
678    if is_alternative {
679        let chosen = alt_plain.or(alt_html);
680        if let Some((text, charset)) = chosen {
681            return (text, charset);
682        }
683        return (result, detected_charset);
684    }
685
686    (result, detected_charset)
687}
688
689fn extract_filename(headers: &[(String, String)]) -> Option<String> {
690    for (name, value) in headers {
691        if name == "content-disposition" || name == "content-type" {
692            // Try RFC 2231 continuation first (filename*0=, filename*1=, ...)
693            if let Some(f) = extract_rfc2231_filename(value) {
694                return Some(f);
695            }
696            // Try RFC 2231 charset encoding (filename*=charset'lang'value)
697            if let Some(f) = extract_rfc2231_encoded_filename(value) {
698                return Some(f);
699            }
700            // Fall back to simple filename= or name=
701            for param in value.split(';') {
702                let param = param.trim();
703                if let Some(filename_part) =
704                    param.strip_prefix("filename=").or_else(|| param.strip_prefix("name="))
705                {
706                    let filename = filename_part.trim().trim_matches('"').trim_matches('\'');
707                    if !filename.is_empty() {
708                        return Some(filename.to_string());
709                    }
710                }
711            }
712        }
713    }
714    None
715}
716
717fn extract_rfc2231_filename(value: &str) -> Option<String> {
718    // RFC 2231 allows arbitrarily many continuation segments (filename*0=, *1=, ...).
719    // A pathological message could supply many huge segments that concatenate into
720    // hundreds of MB of `String`. Cap the reassembled length to defend against
721    // memory exhaustion; legitimate filenames are well under this.
722    const MAX_FILENAME_LEN: usize = 8 * 1024;
723
724    let mut parts: Vec<(usize, String)> = Vec::new();
725    for param in value.split(';') {
726        let param = param.trim();
727        for prefix in &["filename*", "name*"] {
728            if let Some(rest) = param.strip_prefix(prefix) {
729                if let Some(eq_pos) = rest.find('=') {
730                    let num_part = &rest[..eq_pos];
731                    let val_part = &rest[eq_pos + 1..];
732                    let num_str = num_part.trim_end_matches('*');
733                    if let Ok(idx) = num_str.parse::<usize>() {
734                        let val = val_part.trim().trim_matches('"').trim_matches('\'');
735                        let decoded = if num_part.ends_with('*') {
736                            decode_rfc2231_value(val)
737                        } else {
738                            val.to_string()
739                        };
740                        parts.push((idx, decoded));
741                    }
742                }
743            }
744        }
745    }
746    if parts.is_empty() {
747        return None;
748    }
749    parts.sort_by_key(|(idx, _)| *idx);
750    let mut result = String::new();
751    for (_, v) in parts {
752        if result.len().saturating_add(v.len()) > MAX_FILENAME_LEN {
753            // Truncate rather than fail the whole parse — the partial filename
754            // is still safer than `None` triggering downstream defaults that
755            // might leak the original.
756            let remaining = MAX_FILENAME_LEN.saturating_sub(result.len());
757            if remaining > 0 {
758                let take = v
759                    .char_indices()
760                    .take_while(|(i, _)| *i <= remaining)
761                    .last()
762                    .map(|(i, c)| i + c.len_utf8())
763                    .unwrap_or(0);
764                result.push_str(&v[..take.min(v.len())]);
765            }
766            break;
767        }
768        result.push_str(&v);
769    }
770    if result.is_empty() {
771        None
772    } else {
773        Some(result)
774    }
775}
776
777fn extract_rfc2231_encoded_filename(value: &str) -> Option<String> {
778    for param in value.split(';') {
779        let param = param.trim();
780        for prefix in &["filename*=", "name*="] {
781            if let Some(rest) = param.strip_prefix(prefix) {
782                let val = rest.trim().trim_matches('"');
783                return Some(decode_rfc2231_value(val));
784            }
785        }
786    }
787    None
788}
789
790fn decode_rfc2231_value(value: &str) -> String {
791    let parts: Vec<&str> = value.splitn(3, '\'').collect();
792    if parts.len() == 3 {
793        let charset = parts[0];
794        let encoded = parts[2];
795        let decoded_bytes = percent_decode_bytes(encoded);
796        let encoding =
797            encoding_rs::Encoding::for_label(charset.as_bytes()).unwrap_or(encoding_rs::UTF_8);
798        let (result, _, _) = encoding.decode(&decoded_bytes);
799        result.into_owned()
800    } else {
801        let decoded_bytes = percent_decode_bytes(value);
802        String::from_utf8_lossy(&decoded_bytes).into_owned()
803    }
804}
805
806fn percent_decode_bytes(input: &str) -> Vec<u8> {
807    let mut result = Vec::with_capacity(input.len());
808    let bytes = input.as_bytes();
809    let mut i = 0;
810    while i < bytes.len() {
811        if bytes[i] == b'%' && i + 2 < bytes.len() {
812            if let Ok(byte) =
813                u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
814            {
815                result.push(byte);
816                i += 3;
817                continue;
818            }
819        }
820        result.push(bytes[i]);
821        i += 1;
822    }
823    result
824}
825
826/// RFC 3676: Unwrap format=flowed text
827/// Lines ending with a space (SP) are joined with the following line.
828/// Lines beginning with "-- " are signature separators (never flowed).
829pub fn unflow_text(text: &str) -> String {
830    let mut result = String::with_capacity(text.len());
831
832    for line in text.lines() {
833        // Signature separator is never flowed
834        if line == "-- " {
835            result.push_str(line);
836            result.push('\n');
837            continue;
838        }
839
840        if line.ends_with(' ') {
841            result.push_str(line.trim_end_matches(' '));
842            result.push(' ');
843        } else {
844            result.push_str(line);
845            result.push('\n');
846        }
847    }
848    result
849}
850
851#[cfg(test)]
852mod tests {
853    use super::*;
854
855    #[test]
856    fn test_content_type_parse() {
857        let ct = ContentType::parse("text/plain; charset=utf-8");
858        assert_eq!(ct.type_, "text");
859        assert_eq!(ct.subtype, "plain");
860        assert_eq!(ct.charset(), Some("utf-8"));
861    }
862
863    #[test]
864    fn test_content_type_multipart() {
865        let ct = ContentType::parse("multipart/mixed; boundary=\"----=_Part_123\"");
866        assert!(ct.is_multipart());
867        assert_eq!(ct.boundary(), Some("----=_Part_123"));
868    }
869
870    #[test]
871    fn test_content_disposition() {
872        let cd = ContentDisposition::parse("attachment; filename=\"test.pdf\"");
873        assert!(cd.is_attachment());
874        assert_eq!(cd.filename(), Some("test.pdf"));
875    }
876
877    #[test]
878    fn test_content_disposition_inline() {
879        let cd = ContentDisposition::parse("inline");
880        assert!(!cd.is_attachment());
881    }
882
883    #[test]
884    fn test_base64_decode() {
885        let data = b"SGVsbG8gV29ybGQ=";
886        let decoded = decode_base64(data).unwrap();
887        assert_eq!(decoded, b"Hello World");
888    }
889
890    #[test]
891    fn test_base64_decode_with_newlines() {
892        let data = b"SGVs\nbG8g\nV29y\nbGQ=";
893        let decoded = decode_base64(data).unwrap();
894        assert_eq!(decoded, b"Hello World");
895    }
896
897    #[test]
898    fn test_quoted_printable_decode() {
899        let data = b"=48=C3=A5kan";
900        let decoded = decode_quoted_printable(data);
901        assert_eq!(std::str::from_utf8(&decoded).unwrap(), "Håkan");
902    }
903
904    #[test]
905    fn test_quoted_printable_soft_break() {
906        let data = b"line=\r\ncontinued";
907        let decoded = decode_quoted_printable(data);
908        assert_eq!(std::str::from_utf8(&decoded).unwrap(), "linecontinued");
909    }
910
911    #[test]
912    fn test_quoted_printable_soft_break_unix_lf_only() {
913        // Unix-style soft break: =\n without \r (common in Unix-originated emails)
914        let data = b"line=\ncontinued";
915        let decoded = decode_quoted_printable(data);
916        assert_eq!(
917            std::str::from_utf8(&decoded).unwrap(),
918            "linecontinued",
919            "=\\n soft break (without \\r) should be handled"
920        );
921    }
922
923    #[test]
924    fn test_quoted_printable_soft_break_mixed() {
925        // Mix of Unix and DOS soft breaks
926        let data = b"part1=\npart2=\r\npart3";
927        let decoded = decode_quoted_printable(data);
928        assert_eq!(std::str::from_utf8(&decoded).unwrap(), "part1part2part3");
929    }
930
931    #[test]
932    fn test_uuencode_simple() {
933        let data = b"begin 644 test.txt\n+5B5C(&%L9&%C\n`\nend\n";
934        let decoded = decode_uuencode(data);
935        assert!(decoded.is_some());
936        assert!(!decoded.unwrap().is_empty());
937    }
938
939    #[test]
940    fn test_parse_mime_info() {
941        let headers = vec![
942            ("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string()),
943            ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
944        ];
945        let mi = parse_mime_info(&headers).unwrap();
946        assert_eq!(mi.content_type.charset(), Some("iso-8859-1"));
947        assert_eq!(mi.content_transfer_encoding.as_deref(), Some("quoted-printable"));
948    }
949
950    #[test]
951    fn test_parse_mime_info_no_cte() {
952        let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
953        let mi = parse_mime_info(&headers).unwrap();
954        assert_eq!(mi.content_type.charset(), Some("utf-8"));
955        assert!(mi.content_transfer_encoding.is_none());
956    }
957
958    #[test]
959    fn test_parse_mime_info_no_ct() {
960        let headers: Vec<(String, String)> = vec![("from".to_string(), "a@b.com".to_string())];
961        assert!(parse_mime_info(&headers).is_none());
962    }
963
964    #[test]
965    fn test_decode_body_base64() {
966        let headers = vec![
967            ("content-type".to_string(), "text/plain; charset=utf-8".to_string()),
968            ("content-transfer-encoding".to_string(), "base64".to_string()),
969        ];
970        let mi = parse_mime_info(&headers).unwrap();
971        let body = b"SGVsbG8gV29ybGQ=";
972        let decoded = decode_body(body, &mi);
973        assert_eq!(decoded, "Hello World");
974    }
975
976    #[test]
977    fn test_decode_body_quoted_printable() {
978        let headers = vec![
979            ("content-type".to_string(), "text/plain; charset=utf-8".to_string()),
980            ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
981        ];
982        let mi = parse_mime_info(&headers).unwrap();
983        let body = b"Hello=20World=21";
984        let decoded = decode_body(body, &mi);
985        assert_eq!(decoded, "Hello World!");
986    }
987
988    #[test]
989    fn test_decode_body_7bit_passthrough() {
990        let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
991        let mi = parse_mime_info(&headers).unwrap();
992        let body = b"Hello World";
993        let decoded = decode_body(body, &mi);
994        assert_eq!(decoded, "Hello World");
995    }
996
997    #[test]
998    fn test_decode_body_charset_iso8859_1() {
999        let headers = vec![
1000            ("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string()),
1001            ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
1002        ];
1003        let mi = parse_mime_info(&headers).unwrap();
1004        // "H=E5kan" with iso-8859-1: å = 0xE5 = 229
1005        let body = b"H=E5kan";
1006        let decoded = decode_body(body, &mi);
1007        assert_eq!(decoded, "Håkan");
1008    }
1009
1010    #[test]
1011    fn test_process_mime_body_no_mime() {
1012        let headers = vec![("from".to_string(), "a@b.com".to_string())];
1013        let (body, charset) = process_mime_body(&headers, b"Hello World");
1014        assert_eq!(body, "Hello World");
1015        assert!(charset.is_none());
1016    }
1017
1018    #[test]
1019    fn test_process_mime_body_with_charset() {
1020        let headers =
1021            vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
1022        let (body, charset) = process_mime_body(&headers, b"Hello");
1023        assert_eq!(body, "Hello");
1024        assert_eq!(charset.as_deref(), Some("iso-8859-1"));
1025    }
1026
1027    #[test]
1028    fn test_decode_body_iso_8859_7_kalimera() {
1029        let headers =
1030            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1031        let mi = parse_mime_info(&headers).unwrap();
1032        // "Καλημερα" in ISO-8859-7: Κ=0xCA α=0xE1 λ=0xEB η=0xE7 μ=0xEC ε=0xE5 ρ=0xF1 α=0xE1
1033        let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
1034        let decoded = decode_body(body, &mi);
1035        assert_eq!(decoded, "Καλημερα");
1036    }
1037
1038    #[test]
1039    fn test_decode_body_windows_1253_kalimera() {
1040        let headers =
1041            vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1042        let mi = parse_mime_info(&headers).unwrap();
1043        // "Καλημερα" in Windows-1253 (same code points for unaccented Greek)
1044        let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
1045        let decoded = decode_body(body, &mi);
1046        assert_eq!(decoded, "Καλημερα");
1047    }
1048
1049    #[test]
1050    fn test_decode_body_iso_8859_7_tonos() {
1051        let headers =
1052            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1053        let mi = parse_mime_info(&headers).unwrap();
1054        // "άνθρωπος" in ISO-8859-7: ά=0xDC ν=0xED θ=0xE8 ρ=0xF1 ω=0xF9 π=0xF0 ο=0xEF ς=0xF2
1055        let body = b"\xDC\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1056        let decoded = decode_body(body, &mi);
1057        assert_eq!(decoded, "άνθρωπος");
1058    }
1059
1060    #[test]
1061    fn test_decode_body_windows_1253_tonos() {
1062        let headers =
1063            vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1064        let mi = parse_mime_info(&headers).unwrap();
1065        // "άνθρωπος" in Windows-1253: ά=0xDC ν=0xED θ=0xE8 ρ=0xF1 ω=0xF9 π=0xF0 ο=0xEF ς=0xF2
1066        let body = b"\xDC\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1067        let decoded = decode_body(body, &mi);
1068        assert_eq!(decoded, "άνθρωπος");
1069    }
1070
1071    #[test]
1072    fn test_decode_body_no_charset_iso_8859_7_fallback() {
1073        let headers = vec![("content-type".to_string(), "text/plain".to_string())];
1074        let mi = parse_mime_info(&headers).unwrap();
1075        // "Καλημερα" in ISO-8859-7
1076        let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
1077        let decoded = decode_body(body, &mi);
1078        assert_eq!(decoded, "Καλημερα");
1079    }
1080
1081    #[test]
1082    fn test_decode_body_iso_8859_7_quoted_printable() {
1083        let headers = vec![
1084            ("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string()),
1085            ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
1086        ];
1087        let mi = parse_mime_info(&headers).unwrap();
1088        // QP-encoded "Καλημερα": Κ=CA α=E1 λ=EB η=E7 μ=EC ε=E5 ρ=F1 α=E1
1089        let body = b"\xCA=E1=EB=E7=EC=E5=F1=E1";
1090        let decoded = decode_body(body, &mi);
1091        assert_eq!(decoded, "Καλημερα");
1092    }
1093
1094    #[test]
1095    fn test_decode_body_iso_8859_7_base64() {
1096        let headers = vec![
1097            ("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string()),
1098            ("content-transfer-encoding".to_string(), "base64".to_string()),
1099        ];
1100        let mi = parse_mime_info(&headers).unwrap();
1101        // Base64 of ISO-8859-7 "Καλημερα" (bytes: CAE1EBE7ECE5F1E1)
1102        let body = b"yuHr5+zl8eE=";
1103        let decoded = decode_body(body, &mi);
1104        assert_eq!(decoded, "Καλημερα");
1105    }
1106
1107    #[test]
1108    fn test_find_multipart_charset_second_part() {
1109        // Multipart where first part has no charset, second part does
1110        let boundary = "----=_NextPart_000_1234";
1111        let body = "------=_NextPart_000_1234\n\
1112             Content-Type: text/plain; format=flowed\n\
1113             \n\
1114             Some plain text\n\
1115             \n\
1116             ------=_NextPart_000_1234\n\
1117             Content-Type: text/html; charset=\"iso-8859-7\"\n\
1118             \n\
1119             <p>Some text</p>\n\
1120             \n\
1121             ------=_NextPart_000_1234--\n"
1122            .to_string();
1123        let result = find_multipart_charset(body.as_bytes(), boundary);
1124        assert_eq!(result.as_deref(), Some("iso-8859-7"));
1125    }
1126
1127    #[test]
1128    fn test_find_multipart_charset_all_parts_no_charset() {
1129        // Multipart where NO part has a charset
1130        let boundary = "----=_NextPart_000_5678";
1131        let body = "------=_NextPart_000_5678\n\
1132             Content-Type: text/plain; format=flowed\n\
1133             \n\
1134             First part\n\
1135             \n\
1136             ------=_NextPart_000_5678\n\
1137             Content-Type: text/plain\n\
1138             \n\
1139             Second part\n\
1140             \n\
1141             ------=_NextPart_000_5678--\n"
1142            .to_string();
1143        let result = find_multipart_charset(body.as_bytes(), boundary);
1144        assert!(result.is_none());
1145    }
1146
1147    #[test]
1148    fn test_process_mime_body_multipart_charset_in_second_part() {
1149        let headers = vec![(
1150            "content-type".to_string(),
1151            "multipart/mixed; boundary=\"----=_NextPart_000_9999\"".to_string(),
1152        )];
1153        let body = b"------=_NextPart_000_9999\n\
1154             Content-Type: text/plain; format=flowed\n\
1155             Content-Transfer-Encoding: 8bit\n\
1156             \n\
1157             Hello\n\
1158             \n\
1159             ------=_NextPart_000_9999\n\
1160             Content-Type: text/html; charset=\"iso-8859-7\"\n\
1161             Content-Transfer-Encoding: 8bit\n\
1162             \n\
1163             \xCB\xE1\xEC\xE7\xED\xE5\xF1\xE1\n\
1164             \n\
1165             ------=_NextPart_000_9999--\n";
1166        let charset = resolve_charset(body, &parse_mime_info(&headers).unwrap());
1167        assert_eq!(
1168            charset.as_deref(),
1169            Some("iso-8859-7"),
1170            "Should detect charset from second part when first part lacks it"
1171        );
1172    }
1173
1174    #[test]
1175    fn test_decode_body_greek_utf8() {
1176        let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
1177        let mi = parse_mime_info(&headers).unwrap();
1178        let body = "Καλημερα".as_bytes();
1179        let decoded = decode_body(body, &mi);
1180        assert_eq!(decoded, "Καλημερα");
1181    }
1182
1183    #[test]
1184    fn test_process_mime_body_no_ct_greek_fallback() {
1185        // No Content-Type header, but body has Greek ISO-8859-7 bytes
1186        let headers = vec![("from".to_string(), "a@b.com".to_string())];
1187        // "Γεια" in ISO-8859-7: Γ=0xC3 ε=0xE5 ι=0xE9 α=0xE1
1188        let body = b"\xC3\xE5\xE9\xE1";
1189        let (decoded, charset) = process_mime_body(&headers, body);
1190        assert!(!decoded.contains('\u{FFFD}'), "Should decode Greek without replacement chars");
1191        // Should have detected a charset from fallbacks
1192        assert!(charset.is_some(), "Should report a detected charset");
1193        assert_eq!(decoded, "Γεια");
1194    }
1195
1196    // Additional comprehensive Greek charset tests
1197
1198    #[test]
1199    fn test_decode_body_uppercase_tonos_iso_8859_7() {
1200        // Test uppercase Greek with tonos: "Άνθρωπος" (capital Ά)
1201        // ISO-8859-7: Ά=0xB6 ν=0xED θ=0xE8 ρ=0xF1 ω=0xF9 π=0xF0 ο=0xEF ς=0xF2
1202        let headers =
1203            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1204        let mi = parse_mime_info(&headers).unwrap();
1205        let body = b"\xB6\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1206        let decoded = decode_body(body, &mi);
1207        assert_eq!(decoded, "Άνθρωπος");
1208    }
1209
1210    #[test]
1211    fn test_decode_body_uppercase_tonos_windows_1253() {
1212        // Test uppercase Greek with tonos: "Άνθρωπος" (capital Ά)
1213        // Windows-1253: Ά=0xA2 ν=0xED θ=0xE8 ρ=0xF1 ω=0xF9 π=0xF0 ο=0xEF ς=0xF2
1214        let headers =
1215            vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1216        let mi = parse_mime_info(&headers).unwrap();
1217        let body = b"\xA2\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1218        let decoded = decode_body(body, &mi);
1219        assert_eq!(decoded, "Άνθρωπος");
1220    }
1221
1222    #[test]
1223    fn test_decode_body_real_world_greek_phrase() {
1224        // Real-world phrase: "Καλό απόγευμα" (Good afternoon)
1225        // ISO-8859-7: Κ=0xCA α=0xE1 λ=0xEB ό=0xFC <space> α=0xE1 π=0xF0 ό=0xFC γ=0xE3 ε=0xE5 υ=0xF5 μ=0xEC α=0xE1
1226        let headers =
1227            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1228        let mi = parse_mime_info(&headers).unwrap();
1229        let body = b"\xCA\xE1\xEB\xFC\x20\xE1\xF0\xFC\xE3\xE5\xF5\xEC\xE1";
1230        let decoded = decode_body(body, &mi);
1231        assert_eq!(decoded, "Καλό απόγευμα");
1232    }
1233
1234    #[test]
1235    fn test_decode_body_mixed_greek_latin() {
1236        // Mixed text: "Hello Κόσμε!" (Hello World! in mixed Greek/Latin)
1237        // UTF-8 encoding for the Greek part
1238        let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
1239        let mi = parse_mime_info(&headers).unwrap();
1240        let body = "Hello Κόσμε!".as_bytes();
1241        let decoded = decode_body(body, &mi);
1242        assert_eq!(decoded, "Hello Κόσμε!");
1243    }
1244
1245    #[test]
1246    fn test_decode_body_question_marks_greek() {
1247        // Greek semicolon (U+037E) looks like ";" and question mark is ";"
1248        // "Πώς είσαι;" (How are you?)
1249        // ISO-8859-7: Π=0xD0 ώ=0xFE ς=0xF2 <space> ε=0xE5 ί=0xDF σ=0xF3 α=0xE1 ι=0xE9 ;
1250        let headers =
1251            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1252        let mi = parse_mime_info(&headers).unwrap();
1253        let body = b"\xD0\xFE\xF2\x20\xE5\xDF\xF3\xE1\xE9;";
1254        let decoded = decode_body(body, &mi);
1255        assert_eq!(decoded, "Πώς είσαι;");
1256    }
1257
1258    #[test]
1259    fn test_find_multipart_charset_mixed_encodings() {
1260        // Multipart with first part in UTF-8 (no charset param), second in ISO-8859-7
1261        let boundary = "----=_Part_123";
1262        let body = "------=_Part_123\n\
1263             Content-Type: text/plain\n\
1264             \n\
1265             English text\n\
1266             \n\
1267             ------=_Part_123\n\
1268             Content-Type: text/html; charset=\"iso-8859-7\"\n\
1269             \n\
1270             <p>Greek text</p>\n\
1271             \n\
1272             ------=_Part_123--\n"
1273            .to_string();
1274        let result = find_multipart_charset(body.as_bytes(), boundary);
1275        assert_eq!(
1276            result.as_deref(),
1277            Some("iso-8859-7"),
1278            "Should find charset from second part even when first part has none"
1279        );
1280    }
1281
1282    #[test]
1283    fn test_process_mime_body_multipart_with_greek_html() {
1284        // Real-world scenario: multipart/alternative with Greek HTML
1285        let headers = vec![(
1286            "content-type".to_string(),
1287            "multipart/alternative; boundary=\"----=_NextPart_000_1111\"".to_string(),
1288        )];
1289        let body = b"------=_NextPart_000_1111\n\
1290             Content-Type: text/plain; charset=\"iso-8859-7\"\n\
1291             \n\
1292             \xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1\n\
1293             \n\
1294             ------=_NextPart_000_1111\n\
1295             Content-Type: text/html; charset=\"iso-8859-7\"\n\
1296             \n\
1297             <html><body>\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1</body></html>\n\
1298             \n\
1299             ------=_NextPart_000_1111--\n";
1300        let (decoded, charset) = process_mime_body(&headers, body);
1301        assert_eq!(charset.as_deref(), Some("iso-8859-7"));
1302        // Should decode Greek correctly from first text/plain part
1303        assert!(decoded.contains("Καλημερα"), "Should contain decoded Greek text");
1304        assert!(!decoded.contains('\u{FFFD}'), "Should not have replacement characters");
1305    }
1306
1307    #[test]
1308    fn test_decode_body_all_greek_letters_iso_8859_7() {
1309        // Test basic Greek alphabet (lowercase): α β γ δ ε
1310        // ISO-8859-7: α=0xE1 β=0xE2 γ=0xE3 δ=0xE4 ε=0xE5
1311        let headers =
1312            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1313        let mi = parse_mime_info(&headers).unwrap();
1314        let body = b"\xE1\xE2\xE3\xE4\xE5";
1315        let decoded = decode_body(body, &mi);
1316        assert_eq!(decoded, "αβγδε");
1317    }
1318
1319    #[test]
1320    fn test_decode_body_all_greek_letters_windows_1253() {
1321        // Test basic Greek alphabet (uppercase): Α Β Γ Δ Ε
1322        // Windows-1253: Α=0xC1 Β=0xC2 Γ=0xC3 Δ=0xC4 Ε=0xC5
1323        let headers =
1324            vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1325        let mi = parse_mime_info(&headers).unwrap();
1326        let body = b"\xC1\xC2\xC3\xC4\xC5";
1327        let decoded = decode_body(body, &mi);
1328        assert_eq!(decoded, "ΑΒΓΔΕ");
1329    }
1330
1331    #[test]
1332    fn test_decode_body_diaeresis_greek() {
1333        // Test Greek with diaeresis: "ϊδιος" (same, with diaeresis on iota)
1334        // ISO-8859-7: ϊ=0xFA δ=0xE4 ι=0xE9 ο=0xEF ς=0xF2
1335        let headers =
1336            vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1337        let mi = parse_mime_info(&headers).unwrap();
1338        let body = b"\xFA\xE4\xE9\xEF\xF2";
1339        let decoded = decode_body(body, &mi);
1340        assert_eq!(decoded, "ϊδιος");
1341    }
1342
1343    #[test]
1344    fn test_multipart_inline_image() {
1345        // Test multipart message with inline image (Content-Disposition: inline)
1346        let headers = vec![(
1347            "content-type".to_string(),
1348            "multipart/mixed; boundary=\"----=_Part_123\"".to_string(),
1349        )];
1350
1351        // Create a small 1x1 red pixel GIF
1352        let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1353
1354        let body = format!(
1355            "------=_Part_123\n\
1356             Content-Type: text/plain; charset=utf-8\n\
1357             \n\
1358             Hello world\n\
1359             \n\
1360             ------=_Part_123\n\
1361             Content-Type: image/gif; name=\"pixel.gif\"\n\
1362             Content-Disposition: inline; filename=\"pixel.gif\"\n\
1363             Content-Transfer-Encoding: base64\n\
1364             \n\
1365             {}\n\
1366             \n\
1367             ------=_Part_123--\n",
1368            std::str::from_utf8(gif_bytes).unwrap()
1369        );
1370
1371        let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1372
1373        // Should contain the text part
1374        assert!(decoded.contains("Hello world"), "Should contain text content");
1375
1376        // Should contain inline image marker (not escaped HTML)
1377        assert!(
1378            decoded.contains("[INLINE_IMAGE:image/gif:"),
1379            "Should contain inline image marker"
1380        );
1381        assert!(
1382            decoded.contains("R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
1383            "Should contain base64 data in marker"
1384        );
1385
1386        // Should NOT show MIME boundaries
1387        assert!(!decoded.contains("------=_Part_123"), "Should not contain MIME boundaries");
1388    }
1389
1390    #[test]
1391    fn test_multipart_attachment_image() {
1392        // Images are always embedded inline in the HTML archive regardless of
1393        // Content-Disposition: attachment — browsers browse, they don't download.
1394        let headers = vec![(
1395            "content-type".to_string(),
1396            "multipart/mixed; boundary=\"----=_Part_456\"".to_string(),
1397        )];
1398
1399        let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1400
1401        let body = format!(
1402            "------=_Part_456\n\
1403             Content-Type: text/plain; charset=utf-8\n\
1404             \n\
1405             See attached image\n\
1406             \n\
1407             ------=_Part_456\n\
1408             Content-Type: image/gif; name=\"chart.gif\"\n\
1409             Content-Disposition: attachment; filename=\"chart.gif\"\n\
1410             Content-Transfer-Encoding: base64\n\
1411             \n\
1412             {}\n\
1413             \n\
1414             ------=_Part_456--\n",
1415            std::str::from_utf8(gif_bytes).unwrap()
1416        );
1417
1418        let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1419
1420        // Should contain the text part
1421        assert!(decoded.contains("See attached image"), "Should contain text content");
1422
1423        // Image must be embedded inline regardless of Content-Disposition: attachment
1424        assert!(
1425            decoded.contains("[INLINE_IMAGE:image/gif:"),
1426            "Should embed image inline even when Content-Disposition is attachment"
1427        );
1428        assert!(
1429            !decoded.contains("[Attachment: chart.gif]"),
1430            "Should NOT show attachment notation for images"
1431        );
1432    }
1433
1434    #[test]
1435    fn test_multipart_image_with_content_id() {
1436        // Test multipart message with image referenced by Content-ID (for HTML email)
1437        let headers = vec![(
1438            "content-type".to_string(),
1439            "multipart/related; boundary=\"----=_Part_789\"".to_string(),
1440        )];
1441
1442        let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1443
1444        let body = format!(
1445            "------=_Part_789\n\
1446             Content-Type: text/html; charset=utf-8\n\
1447             \n\
1448             <html><body>Logo: <img src=\"cid:logo@example.com\"></body></html>\n\
1449             \n\
1450             ------=_Part_789\n\
1451             Content-Type: image/gif; name=\"logo.gif\"\n\
1452             Content-ID: <logo@example.com>\n\
1453             Content-Transfer-Encoding: base64\n\
1454             \n\
1455             {}\n\
1456             \n\
1457             ------=_Part_789--\n",
1458            std::str::from_utf8(gif_bytes).unwrap()
1459        );
1460
1461        let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1462
1463        // Should contain HTML part
1464        assert!(decoded.contains("<html>"), "Should contain HTML content");
1465
1466        // Should contain inline image marker (since it has Content-ID)
1467        assert!(
1468            decoded.contains("[INLINE_IMAGE:image/gif:"),
1469            "Should contain inline image marker for Content-ID image"
1470        );
1471    }
1472
1473    #[test]
1474    fn test_multipart_pdf_attachment() {
1475        // Test multipart message with PDF attachment
1476        let headers = vec![(
1477            "content-type".to_string(),
1478            "multipart/mixed; boundary=\"----=_Part_PDF\"".to_string(),
1479        )];
1480
1481        let pdf_bytes = b"JVBERi0xLjQKJeLjz9M="; // Minimal PDF header in base64
1482
1483        let body = format!(
1484            "------=_Part_PDF\n\
1485             Content-Type: text/plain; charset=utf-8\n\
1486             \n\
1487             Please review the attached document.\n\
1488             \n\
1489             ------=_Part_PDF\n\
1490             Content-Type: application/pdf; name=\"report.pdf\"\n\
1491             Content-Disposition: attachment; filename=\"report.pdf\"\n\
1492             Content-Transfer-Encoding: base64\n\
1493             \n\
1494             {}\n\
1495             \n\
1496             ------=_Part_PDF--\n",
1497            std::str::from_utf8(pdf_bytes).unwrap()
1498        );
1499
1500        let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1501
1502        // Should contain the text part
1503        assert!(decoded.contains("Please review"), "Should contain text content");
1504
1505        // Should show attachment notation for PDF
1506        assert!(
1507            decoded.contains("[Attachment: report.pdf]"),
1508            "Should show PDF attachment notation"
1509        );
1510
1511        // Should NOT show raw base64 PDF data
1512        assert!(!decoded.contains("JVBERi0xLjQKJeLjz9M="), "Should not contain raw PDF base64");
1513        assert!(!decoded.contains("application/pdf"), "Should not show content-type in output");
1514    }
1515
1516    #[test]
1517    fn test_multipart_mixed_inline_and_attachment() {
1518        // Both inline and attachment-disposition images are now always embedded inline.
1519        let headers = vec![(
1520            "content-type".to_string(),
1521            "multipart/mixed; boundary=\"----=_Part_MIX\"".to_string(),
1522        )];
1523
1524        let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1525
1526        let body = format!(
1527            "------=_Part_MIX\n\
1528             Content-Type: text/plain; charset=utf-8\n\
1529             \n\
1530             Email body text\n\
1531             \n\
1532             ------=_Part_MIX\n\
1533             Content-Type: image/gif; name=\"inline.gif\"\n\
1534             Content-Disposition: inline; filename=\"inline.gif\"\n\
1535             Content-Transfer-Encoding: base64\n\
1536             \n\
1537             {}\n\
1538             \n\
1539             ------=_Part_MIX\n\
1540             Content-Type: image/jpeg; name=\"photo.jpg\"\n\
1541             Content-Disposition: attachment; filename=\"photo.jpg\"\n\
1542             Content-Transfer-Encoding: base64\n\
1543             \n\
1544             {}\n\
1545             \n\
1546             ------=_Part_MIX--\n",
1547            std::str::from_utf8(gif_bytes).unwrap(),
1548            std::str::from_utf8(gif_bytes).unwrap()
1549        );
1550
1551        let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1552
1553        // Should contain text
1554        assert!(decoded.contains("Email body text"), "Should contain text content");
1555
1556        // Both images must be embedded inline — disposition is irrelevant for archives
1557        let inline_count = decoded.matches("[INLINE_IMAGE:").count();
1558        assert_eq!(inline_count, 2, "Both images (inline + attachment) should be embedded");
1559
1560        assert!(!decoded.contains("[Attachment: "), "No image should remain as attachment link");
1561    }
1562
1563    #[test]
1564    fn test_multipart_greek_text_with_inline_image() {
1565        // Real-world test: Greek text with inline image
1566        let headers = vec![(
1567            "content-type".to_string(),
1568            "multipart/mixed; boundary=\"----=_Part_GR\"".to_string(),
1569        )];
1570
1571        // "Γεια σου" in ISO-8859-7: Γ=0xC3 ε=0xE5 ι=0xE9 α=0xE1 space σ=0xF3 ο=0xEF υ=0xF5
1572        let greek_text = vec![0xC3u8, 0xE5, 0xE9, 0xE1, 0x20, 0xF3, 0xEF, 0xF5];
1573        let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1574
1575        let mut body =
1576            b"------=_Part_GR\nContent-Type: text/plain; charset=iso-8859-7\n\n".to_vec();
1577        body.extend_from_slice(&greek_text);
1578        body.extend_from_slice(b"\n\n------=_Part_GR\n");
1579        body.extend_from_slice(b"Content-Type: image/gif; name=\"icon.gif\"\n");
1580        body.extend_from_slice(b"Content-Disposition: inline; filename=\"icon.gif\"\n");
1581        body.extend_from_slice(b"Content-Transfer-Encoding: base64\n\n");
1582        body.extend_from_slice(gif_bytes);
1583        body.extend_from_slice(b"\n\n------=_Part_GR--\n");
1584
1585        let (decoded, charset) = process_mime_body(&headers, &body);
1586
1587        // Should detect ISO-8859-7 charset
1588        assert_eq!(charset.as_deref(), Some("iso-8859-7"), "Should detect Greek charset");
1589
1590        // Should contain decoded Greek text
1591        assert!(decoded.contains("Γεια σου"), "Should contain decoded Greek text");
1592
1593        // Should contain inline image marker
1594        assert!(
1595            decoded.contains("[INLINE_IMAGE:image/gif:"),
1596            "Should contain inline image marker"
1597        );
1598
1599        // Should NOT have mojibake or replacement characters
1600        assert!(!decoded.contains('\u{FFFD}'), "Should not have replacement characters");
1601    }
1602
1603    #[test]
1604    fn test_decode_body_mislabeled_iso_8859_1_as_greek() {
1605        // Real-world case: Body labeled as iso-8859-1 but contains Greek (iso-8859-7)
1606        // Greek text: "Σωστά όλα αυτά" (Correct, all that)
1607        // In iso-8859-7: Σ=0xD3 ω=0xF9 σ=0xF3 τ=0xF4 ά=0xDC space=0x20 ό=0xFC λ=0xEB α=0xE1
1608        let headers =
1609            vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
1610        let mi = parse_mime_info(&headers).unwrap();
1611        let body = b"\xD3\xF9\xF3\xF4\xDC\x20\xFC\xEB\xE1\x20\xE1\xF5\xF4\xDC";
1612
1613        let decoded = decode_body(body, &mi);
1614
1615        // Should auto-detect Greek despite iso-8859-1 label
1616        assert!(
1617            decoded.contains("Σωστά") || decoded.contains("ωστά"),
1618            "Should detect Greek in mislabeled iso-8859-1 body: got '{}'",
1619            decoded
1620        );
1621
1622        // Should NOT have mojibake
1623        assert!(!decoded.contains("ÓùóôÜ"), "Should not have mojibake: got '{}'", decoded);
1624    }
1625
1626    #[test]
1627    fn test_rfc2231_continuation_filename() {
1628        let headers = vec![(
1629            "content-disposition".to_string(),
1630            "attachment; filename*0=\"very_long_\"; filename*1=\"filename.pdf\"".to_string(),
1631        )];
1632        assert_eq!(extract_filename(&headers), Some("very_long_filename.pdf".to_string()));
1633    }
1634
1635    #[test]
1636    fn test_rfc2231_encoded_filename() {
1637        let headers = vec![(
1638            "content-disposition".to_string(),
1639            "attachment; filename*=utf-8''%C3%A9tude.pdf".to_string(),
1640        )];
1641        assert_eq!(extract_filename(&headers), Some("étude.pdf".to_string()));
1642    }
1643
1644    #[test]
1645    fn test_format_flowed_unwrap() {
1646        let input = "This is a long \nline that was wrapped.\n\nNew paragraph.\n";
1647        let expected = "This is a long line that was wrapped.\n\nNew paragraph.\n";
1648        assert_eq!(unflow_text(input), expected);
1649    }
1650
1651    #[test]
1652    fn test_format_flowed_signature_not_unwrapped() {
1653        let input = "Hello \nworld.\n-- \nSignature\n";
1654        let expected = "Hello world.\n-- \nSignature\n";
1655        assert_eq!(unflow_text(input), expected);
1656    }
1657
1658    #[test]
1659    fn test_decode_body_correct_iso_8859_1_latin_preserved() {
1660        // Verify that actual Latin-1 content is NOT incorrectly "fixed" to Greek
1661        // French: "Café résumé"
1662        let headers =
1663            vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
1664        let mi = parse_mime_info(&headers).unwrap();
1665        let body = b"Caf\xE9 r\xE9sum\xE9";
1666
1667        let decoded = decode_body(body, &mi);
1668
1669        // Should preserve correct Latin-1
1670        assert_eq!(
1671            decoded, "Café résumé",
1672            "Should preserve correct Latin-1 text: got '{}'",
1673            decoded
1674        );
1675    }
1676
1677    #[test]
1678    fn test_content_type_is_text() {
1679        let ct = ContentType::parse("text/html; charset=utf-8");
1680        assert!(ct.is_text());
1681        assert!(!ct.is_multipart());
1682    }
1683
1684    #[test]
1685    fn test_content_type_full_type() {
1686        let ct = ContentType::parse("application/pdf");
1687        assert_eq!(ct.full_type(), "application/pdf");
1688    }
1689
1690    #[test]
1691    fn test_content_type_name_param() {
1692        let ct = ContentType::parse("image/jpeg; name=\"photo.jpg\"");
1693        assert_eq!(ct.name(), Some("photo.jpg"));
1694    }
1695
1696    #[test]
1697    fn test_content_type_no_subtype() {
1698        let ct = ContentType::parse("text");
1699        assert_eq!(ct.type_, "text");
1700        assert_eq!(ct.subtype, "");
1701    }
1702
1703    #[test]
1704    fn test_content_disposition_no_filename() {
1705        let cd = ContentDisposition::parse("inline");
1706        assert_eq!(cd.filename(), None);
1707        assert!(!cd.is_attachment());
1708    }
1709
1710    #[test]
1711    fn test_decode_quoted_printable_underscore_as_space() {
1712        let data = b"Hello_World";
1713        let decoded = decode_quoted_printable(data);
1714        assert_eq!(std::str::from_utf8(&decoded).unwrap(), "Hello World");
1715    }
1716
1717    #[test]
1718    fn test_decode_uuencode_no_begin() {
1719        let data = b"not a uuencoded block";
1720        let result = decode_uuencode(data);
1721        assert!(result.is_none());
1722    }
1723
1724    #[test]
1725    fn test_unflow_text_no_trailing_space() {
1726        let input = "Line one.\nLine two.\n";
1727        let result = unflow_text(input);
1728        assert_eq!(result, "Line one.\nLine two.\n");
1729    }
1730
1731    #[test]
1732    fn test_process_mime_body_format_flowed() {
1733        let headers = vec![(
1734            "content-type".to_string(),
1735            "text/plain; charset=utf-8; format=flowed".to_string(),
1736        )];
1737        let body = b"This is a long \nline that flows.\n";
1738        let (decoded, _) = process_mime_body(&headers, body);
1739        assert!(decoded.contains("This is a long line that flows."), "got: {}", decoded);
1740    }
1741}