1use crate::error::Result;
2
3#[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 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#[derive(Debug, Clone)]
75pub struct ContentDisposition {
76 pub disposition: String,
77 pub params: std::collections::HashMap<String, String>,
78}
79
80impl ContentDisposition {
81 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
129pub 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
143pub 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 i += 3;
152 continue;
153 }
154 if i + 1 < data.len() && data[i + 1] == b'\n' {
155 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
177pub 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#[derive(Debug, Clone)]
245pub struct MimeInfo {
246 pub content_type: ContentType,
247 pub content_transfer_encoding: Option<String>,
248}
249
250pub 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 let start =
274 match body[pos..].windows(boundary_bytes.len()).position(|w| w == boundary_bytes) {
275 Some(offset) => pos + offset,
276 None => break, };
278
279 let after_boundary = &body[start + boundary_bytes.len()..];
280
281 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 pos = start + 1;
289 continue;
290 };
291
292 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 pos = start + boundary_bytes.len();
320 }
321 None
322}
323
324pub 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 _ => 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 if let Some(ref charset) = charset {
353 return crate::headers::decode_to_utf8(&decoded_bytes, charset);
354 }
355
356 if let Ok(s) = std::str::from_utf8(&decoded_bytes) {
358 return s.to_string();
359 }
360
361 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
374fn 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
420const MAX_MULTIPART_DEPTH: u32 = 16;
422
423pub 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 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 let mut decoded = decode_body_with_charset(body_raw, mi, charset.as_deref());
462 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 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 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 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 let after_boundary = &body[start + boundary_bytes.len()..];
518 if after_boundary.starts_with(b"--") {
519 break;
521 }
522
523 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 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 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 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 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 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 if content_type_main.starts_with("image/")
599 && SAFE_IMAGE_TYPES.contains(&content_type_main.as_str())
600 {
601 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 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 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 let (decoded, charset) =
648 process_mime_body_depth(&part_headers, part_body, depth + 1);
649 if is_alternative {
650 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 } 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 pos = start + boundary_bytes.len();
674 }
675
676 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 if let Some(f) = extract_rfc2231_filename(value) {
694 return Some(f);
695 }
696 if let Some(f) = extract_rfc2231_encoded_filename(value) {
698 return Some(f);
699 }
700 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 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 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
826pub fn unflow_text(text: &str) -> String {
830 let mut result = String::with_capacity(text.len());
831
832 for line in text.lines() {
833 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 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 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 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 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 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 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 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 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 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 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 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 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 let headers = vec![("from".to_string(), "a@b.com".to_string())];
1187 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 assert!(charset.is_some(), "Should report a detected charset");
1193 assert_eq!(decoded, "Γεια");
1194 }
1195
1196 #[test]
1199 fn test_decode_body_uppercase_tonos_iso_8859_7() {
1200 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 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 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 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 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 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 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 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 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 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 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 let headers = vec![(
1347 "content-type".to_string(),
1348 "multipart/mixed; boundary=\"----=_Part_123\"".to_string(),
1349 )];
1350
1351 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 assert!(decoded.contains("Hello world"), "Should contain text content");
1375
1376 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 assert!(!decoded.contains("------=_Part_123"), "Should not contain MIME boundaries");
1388 }
1389
1390 #[test]
1391 fn test_multipart_attachment_image() {
1392 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 assert!(decoded.contains("See attached image"), "Should contain text content");
1422
1423 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 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 assert!(decoded.contains("<html>"), "Should contain HTML content");
1465
1466 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 let headers = vec![(
1477 "content-type".to_string(),
1478 "multipart/mixed; boundary=\"----=_Part_PDF\"".to_string(),
1479 )];
1480
1481 let pdf_bytes = b"JVBERi0xLjQKJeLjz9M="; 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 assert!(decoded.contains("Please review"), "Should contain text content");
1504
1505 assert!(
1507 decoded.contains("[Attachment: report.pdf]"),
1508 "Should show PDF attachment notation"
1509 );
1510
1511 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 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 assert!(decoded.contains("Email body text"), "Should contain text content");
1555
1556 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 let headers = vec![(
1567 "content-type".to_string(),
1568 "multipart/mixed; boundary=\"----=_Part_GR\"".to_string(),
1569 )];
1570
1571 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 assert_eq!(charset.as_deref(), Some("iso-8859-7"), "Should detect Greek charset");
1589
1590 assert!(decoded.contains("Γεια σου"), "Should contain decoded Greek text");
1592
1593 assert!(
1595 decoded.contains("[INLINE_IMAGE:image/gif:"),
1596 "Should contain inline image marker"
1597 );
1598
1599 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 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 assert!(
1617 decoded.contains("Σωστά") || decoded.contains("ωστά"),
1618 "Should detect Greek in mislabeled iso-8859-1 body: got '{}'",
1619 decoded
1620 );
1621
1622 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 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 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}