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;
151 continue;
152 }
153 if i + 2 < data.len() {
154 if let (Some(h), Some(l)) = (hex_val(data[i + 1]), hex_val(data[i + 2])) {
155 result.push(h << 4 | l);
156 i += 3;
157 continue;
158 }
159 }
160 }
161 if data[i] == b'_' {
162 result.push(b' ');
163 } else if data[i] != b'\r' {
164 result.push(data[i]);
165 }
166 i += 1;
167 }
168 result
169}
170
171pub fn decode_uuencode(data: &[u8]) -> Option<Vec<u8>> {
173 let text = std::str::from_utf8(data).ok()?;
174 let mut result = Vec::new();
175 let mut in_encoded = false;
176
177 for line in text.lines() {
178 let line = line.trim_end();
179 if line.starts_with("begin ") {
180 in_encoded = true;
181 continue;
182 }
183 if line == "end" || line == "`" {
184 in_encoded = false;
185 continue;
186 }
187 if !in_encoded || line.is_empty() {
188 continue;
189 }
190
191 let bytes = line.as_bytes();
192 if bytes.is_empty() {
193 continue;
194 }
195
196 let count = (bytes[0] as usize - 32) & 0x3f;
197 if count == 0 {
198 continue;
199 }
200
201 let mut buf = [0u8; 3];
202 let mut j = 1;
203 let mut out = 0;
204
205 while j < bytes.len() && out < count {
206 let mut chars = [0u8; 4];
207 let mut n = 0;
208 while n < 4 && j < bytes.len() {
209 chars[n] = bytes[j].wrapping_sub(32) & 0x3f;
210 j += 1;
211 n += 1;
212 }
213
214 if n >= 2 {
215 buf[0] = (chars[0] << 2) | (chars[1] >> 4);
216 }
217 if n >= 3 {
218 buf[1] = (chars[1] << 4) | (chars[2] >> 2);
219 }
220 if n >= 4 {
221 buf[2] = (chars[2] << 6) | chars[3];
222 }
223
224 let to_push = n.saturating_sub(1);
225 result.extend_from_slice(&buf[..to_push]);
226 out += to_push;
227 }
228 }
229
230 if result.is_empty() {
231 None
232 } else {
233 Some(result)
234 }
235}
236
237#[derive(Debug, Clone)]
239pub struct MimeInfo {
240 pub content_type: ContentType,
241 pub content_transfer_encoding: Option<String>,
242}
243
244pub fn parse_mime_info(headers: &[(String, String)]) -> Option<MimeInfo> {
246 let ct_str = headers
247 .iter()
248 .find(|(name, _)| name.eq_ignore_ascii_case("content-type"))
249 .map(|(_, val)| val.as_str())?;
250
251 let content_type = ContentType::parse(ct_str);
252 let cte = headers
253 .iter()
254 .find(|(name, _)| name.eq_ignore_ascii_case("content-transfer-encoding"))
255 .map(|(_, val)| val.trim().to_lowercase());
256
257 Some(MimeInfo { content_type, content_transfer_encoding: cte })
258}
259
260fn find_multipart_charset(body: &[u8], boundary: &str) -> Option<String> {
261 let boundary_tag = format!("--{}", boundary);
262 let boundary_bytes = boundary_tag.as_bytes();
263 let mut pos = 0;
264
265 while pos < body.len() {
266 let start =
268 match body[pos..].windows(boundary_bytes.len()).position(|w| w == boundary_bytes) {
269 Some(offset) => pos + offset,
270 None => break, };
272
273 let after_boundary = &body[start + boundary_bytes.len()..];
274
275 let after_eol = if after_boundary.starts_with(b"\r\n") {
277 &after_boundary[2..]
278 } else if after_boundary.starts_with(b"\n") {
279 &after_boundary[1..]
280 } else {
281 pos = start + 1;
283 continue;
284 };
285
286 let header_end = after_eol
288 .windows(2)
289 .position(|w| w == b"\n\n")
290 .or_else(|| after_eol.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 2));
291
292 if let Some(header_end) = header_end {
293 let part_headers = &after_eol[..header_end];
294 if let Ok(header_block) = std::str::from_utf8(part_headers) {
295 for line in header_block.lines() {
296 let lower = line.to_lowercase();
297 if lower.starts_with("content-type:") {
298 if let Some(charset_start) = lower.find("charset=") {
299 let after = &line[charset_start + 8..];
300 let charset = after.trim().trim_matches('"').trim_matches('\'');
301 let charset =
302 charset.split([';', ' ', '\r', '\n']).next().unwrap_or(charset);
303 if !charset.is_empty() {
304 return Some(charset.to_string());
305 }
306 }
307 }
308 }
309 }
310 }
311
312 pos = start + boundary_bytes.len();
314 }
315 None
316}
317
318pub fn decode_body(body: &[u8], mime_info: &MimeInfo) -> String {
322 let decoded_bytes = match mime_info.content_transfer_encoding.as_deref() {
323 Some("base64") => match decode_base64(body) {
324 Ok(bytes) => bytes,
325 Err(_) => body.to_vec(),
326 },
327 Some("quoted-printable") | Some("qp") => decode_quoted_printable(body),
328 _ => body.to_vec(),
330 };
331
332 let charset: Option<String> =
333 mime_info.content_type.charset().map(|s| s.to_string()).or_else(|| {
334 if mime_info.content_type.is_multipart() {
335 if let Some(boundary) = mime_info.content_type.boundary() {
336 find_multipart_charset(body, boundary)
337 } else {
338 None
339 }
340 } else {
341 None
342 }
343 });
344
345 if let Some(ref charset) = charset {
347 return crate::headers::decode_to_utf8(&decoded_bytes, charset);
348 }
349
350 if let Ok(s) = std::str::from_utf8(&decoded_bytes) {
352 return s.to_string();
353 }
354
355 for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
357 if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
358 let (cow, _, _) = encoding.decode(&decoded_bytes);
359 if !cow.contains('\u{FFFD}') {
360 return cow.into_owned();
361 }
362 }
363 }
364
365 String::from_utf8_lossy(&decoded_bytes).to_string()
366}
367
368fn decode_body_with_charset(body: &[u8], mime_info: &MimeInfo, charset: Option<&str>) -> String {
371 let decoded_bytes = match mime_info.content_transfer_encoding.as_deref() {
372 Some("base64") => match decode_base64(body) {
373 Ok(bytes) => bytes,
374 Err(_) => body.to_vec(),
375 },
376 Some("quoted-printable") | Some("qp") => decode_quoted_printable(body),
377 _ => body.to_vec(),
378 };
379
380 if let Some(cs) = charset {
381 return crate::headers::decode_to_utf8(&decoded_bytes, cs);
382 }
383
384 if let Ok(s) = std::str::from_utf8(&decoded_bytes) {
385 return s.to_string();
386 }
387
388 for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
389 if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
390 let (cow, _, _) = encoding.decode(&decoded_bytes);
391 if !cow.contains('\u{FFFD}') {
392 return cow.into_owned();
393 }
394 }
395 }
396
397 String::from_utf8_lossy(&decoded_bytes).to_string()
398}
399
400fn resolve_charset(body_raw: &[u8], mi: &MimeInfo) -> Option<String> {
401 mi.content_type.charset().map(|s| s.to_string()).or_else(|| {
402 if mi.content_type.is_multipart() {
403 if let Some(boundary) = mi.content_type.boundary() {
404 find_multipart_charset(body_raw, boundary)
405 } else {
406 None
407 }
408 } else {
409 None
410 }
411 })
412}
413
414pub fn process_mime_body(
422 headers: &[(String, String)],
423 body_raw: &[u8],
424) -> (String, Option<String>) {
425 let mi = parse_mime_info(headers);
426 if let Some(ref mi) = mi {
427 if mi.content_type.is_multipart() {
429 if let Some(boundary) = mi.content_type.boundary() {
430 return process_multipart_body(body_raw, boundary, mi);
431 }
432 }
433
434 let charset = resolve_charset(body_raw, mi);
435 let mut decoded = decode_body_with_charset(body_raw, mi, charset.as_deref());
437 if mi
439 .content_type
440 .params
441 .get("format")
442 .map(|v| v.eq_ignore_ascii_case("flowed"))
443 .unwrap_or(false)
444 {
445 decoded = unflow_text(&decoded);
446 }
447 (decoded, charset)
448 } else {
449 if let Ok(s) = std::str::from_utf8(body_raw) {
451 return (s.to_string(), None);
452 }
453 for label in &["windows-1253", "iso-8859-7", "iso-8859-1", "windows-1252"] {
454 if let Some(encoding) = encoding_rs::Encoding::for_label(label.as_bytes()) {
455 let (cow, _, _) = encoding.decode(body_raw);
456 if !cow.contains('\u{FFFD}') {
457 return (cow.into_owned(), Some(label.to_string()));
458 }
459 }
460 }
461 (String::from_utf8_lossy(body_raw).to_string(), None)
462 }
463}
464
465fn process_multipart_body(
466 body: &[u8],
467 boundary: &str,
468 parent_mime: &MimeInfo,
469) -> (String, Option<String>) {
470 let is_alternative = parent_mime.content_type.subtype == "alternative";
471 let boundary_tag = format!("--{}", boundary);
472 let boundary_bytes = boundary_tag.as_bytes();
473 let mut result = String::new();
474 let mut detected_charset = None;
475 let mut pos = 0;
476
477 let mut alt_plain: Option<(String, Option<String>)> = None;
480 let mut alt_html: Option<(String, Option<String>)> = None;
481
482 while pos < body.len() {
483 let start =
485 match body[pos..].windows(boundary_bytes.len()).position(|w| w == boundary_bytes) {
486 Some(offset) => pos + offset,
487 None => break,
488 };
489
490 let after_boundary = &body[start + boundary_bytes.len()..];
492 if after_boundary.starts_with(b"--") {
493 break;
495 }
496
497 let after_eol = if after_boundary.starts_with(b"\r\n") {
499 &after_boundary[2..]
500 } else if after_boundary.starts_with(b"\n") {
501 &after_boundary[1..]
502 } else {
503 pos = start + boundary_bytes.len();
504 continue;
505 };
506
507 let header_end = after_eol
509 .windows(2)
510 .position(|w| w == b"\n\n")
511 .or_else(|| after_eol.windows(4).position(|w| w == b"\r\n\r\n").map(|p| p + 2));
512
513 if let Some(header_end) = header_end {
514 let part_headers_bytes = &after_eol[..header_end];
515 let part_body_start = header_end + 2;
516
517 let part_body = if let Some(next_boundary_pos) = after_eol[part_body_start..]
519 .windows(boundary_bytes.len())
520 .position(|w| w == boundary_bytes)
521 {
522 &after_eol[part_body_start..part_body_start + next_boundary_pos]
523 } else {
524 &after_eol[part_body_start..]
525 };
526
527 if let Ok(headers_str) = std::str::from_utf8(part_headers_bytes) {
529 let mut part_headers = Vec::new();
530 for line in headers_str.lines() {
531 if let Some((name, value)) = line.split_once(':') {
532 part_headers.push((name.trim().to_lowercase(), value.trim().to_string()));
533 }
534 }
535
536 let mut is_attachment = false;
538 let mut _has_content_id = false;
539 let mut content_type_main = String::new();
540 let mut encoding = String::new();
541
542 for (name, value) in &part_headers {
543 if name == "content-disposition" {
544 is_attachment = value.to_lowercase().starts_with("attachment");
545 }
546 if name == "content-id" {
547 _has_content_id = true;
548 }
549 if name == "content-type" {
550 if let Some(main_type) = value.split(';').next() {
551 content_type_main = main_type.trim().to_lowercase();
552 }
553 }
554 if name == "content-transfer-encoding" {
555 encoding = value.trim().to_lowercase();
556 }
557 }
558
559 const SAFE_IMAGE_TYPES: &[&str] = &[
562 "image/gif",
563 "image/jpeg",
564 "image/jpg",
565 "image/png",
566 "image/webp",
567 "image/bmp",
568 "image/tiff",
569 ];
570
571 if content_type_main.starts_with("image/")
573 && SAFE_IMAGE_TYPES.contains(&content_type_main.as_str())
574 {
575 let image_data = if encoding == "base64" {
580 decode_base64(part_body.trim_ascii()).ok()
581 } else if !part_body.is_empty() {
582 Some(part_body.to_vec())
583 } else {
584 None
585 };
586
587 if let Some(data) = image_data {
588 use base64::Engine as _;
589 let engine = base64::engine::general_purpose::STANDARD;
590 let base64_data = engine.encode(&data);
591 if !result.is_empty() {
592 result.push('\n');
593 }
594 result.push_str(&format!(
595 "[INLINE_IMAGE:{}:{}]\n",
596 content_type_main, base64_data
597 ));
598 } else if let Some(filename) = extract_filename(&part_headers) {
599 if !result.is_empty() {
601 result.push('\n');
602 }
603 result.push_str(&format!("[Attachment: {}]\n", filename));
604 }
605 } else if content_type_main.starts_with("image/")
606 || is_attachment
607 || content_type_main.starts_with("application/")
608 {
609 if let Some(filename) = extract_filename(&part_headers) {
611 if !result.is_empty() {
612 result.push('\n');
613 }
614 result.push_str(&format!("[Attachment: {}]\n", filename));
615 }
616 } else if content_type_main.starts_with("text/") || content_type_main.is_empty() {
617 let (decoded, charset) = process_mime_body(&part_headers, part_body);
619 if is_alternative {
620 if content_type_main == "text/plain" || content_type_main.is_empty() {
622 if alt_plain.is_none() {
623 alt_plain = Some((decoded, charset));
624 }
625 } else if content_type_main == "text/html" && alt_html.is_none() {
626 alt_html = Some((decoded, charset));
627 }
628 } else {
630 if detected_charset.is_none() && charset.is_some() {
631 detected_charset = charset;
632 }
633 if !result.is_empty() && !decoded.is_empty() {
634 result.push('\n');
635 }
636 result.push_str(&decoded);
637 }
638 }
639 }
640 }
641
642 pos = start + boundary_bytes.len();
644 }
645
646 if is_alternative {
649 let chosen = alt_plain.or(alt_html);
650 if let Some((text, charset)) = chosen {
651 return (text, charset);
652 }
653 return (result, detected_charset);
654 }
655
656 (result, detected_charset)
657}
658
659fn extract_filename(headers: &[(String, String)]) -> Option<String> {
660 for (name, value) in headers {
661 if name == "content-disposition" || name == "content-type" {
662 if let Some(f) = extract_rfc2231_filename(value) {
664 return Some(f);
665 }
666 if let Some(f) = extract_rfc2231_encoded_filename(value) {
668 return Some(f);
669 }
670 for param in value.split(';') {
672 let param = param.trim();
673 if let Some(filename_part) =
674 param.strip_prefix("filename=").or_else(|| param.strip_prefix("name="))
675 {
676 let filename = filename_part.trim().trim_matches('"').trim_matches('\'');
677 if !filename.is_empty() {
678 return Some(filename.to_string());
679 }
680 }
681 }
682 }
683 }
684 None
685}
686
687fn extract_rfc2231_filename(value: &str) -> Option<String> {
688 let mut parts: Vec<(usize, String)> = Vec::new();
689 for param in value.split(';') {
690 let param = param.trim();
691 for prefix in &["filename*", "name*"] {
692 if let Some(rest) = param.strip_prefix(prefix) {
693 if let Some(eq_pos) = rest.find('=') {
694 let num_part = &rest[..eq_pos];
695 let val_part = &rest[eq_pos + 1..];
696 let num_str = num_part.trim_end_matches('*');
697 if let Ok(idx) = num_str.parse::<usize>() {
698 let val = val_part.trim().trim_matches('"').trim_matches('\'');
699 let decoded = if num_part.ends_with('*') {
700 decode_rfc2231_value(val)
701 } else {
702 val.to_string()
703 };
704 parts.push((idx, decoded));
705 }
706 }
707 }
708 }
709 }
710 if parts.is_empty() {
711 return None;
712 }
713 parts.sort_by_key(|(idx, _)| *idx);
714 let result: String = parts.into_iter().map(|(_, v)| v).collect();
715 if result.is_empty() {
716 None
717 } else {
718 Some(result)
719 }
720}
721
722fn extract_rfc2231_encoded_filename(value: &str) -> Option<String> {
723 for param in value.split(';') {
724 let param = param.trim();
725 for prefix in &["filename*=", "name*="] {
726 if let Some(rest) = param.strip_prefix(prefix) {
727 let val = rest.trim().trim_matches('"');
728 return Some(decode_rfc2231_value(val));
729 }
730 }
731 }
732 None
733}
734
735fn decode_rfc2231_value(value: &str) -> String {
736 let parts: Vec<&str> = value.splitn(3, '\'').collect();
737 if parts.len() == 3 {
738 let charset = parts[0];
739 let encoded = parts[2];
740 let decoded_bytes = percent_decode_bytes(encoded);
741 let encoding =
742 encoding_rs::Encoding::for_label(charset.as_bytes()).unwrap_or(encoding_rs::UTF_8);
743 let (result, _, _) = encoding.decode(&decoded_bytes);
744 result.into_owned()
745 } else {
746 let decoded_bytes = percent_decode_bytes(value);
747 String::from_utf8_lossy(&decoded_bytes).into_owned()
748 }
749}
750
751fn percent_decode_bytes(input: &str) -> Vec<u8> {
752 let mut result = Vec::with_capacity(input.len());
753 let bytes = input.as_bytes();
754 let mut i = 0;
755 while i < bytes.len() {
756 if bytes[i] == b'%' && i + 2 < bytes.len() {
757 if let Ok(byte) =
758 u8::from_str_radix(std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or(""), 16)
759 {
760 result.push(byte);
761 i += 3;
762 continue;
763 }
764 }
765 result.push(bytes[i]);
766 i += 1;
767 }
768 result
769}
770
771pub fn unflow_text(text: &str) -> String {
775 let mut result = String::with_capacity(text.len());
776
777 for line in text.lines() {
778 if line == "-- " {
780 result.push_str(line);
781 result.push('\n');
782 continue;
783 }
784
785 if line.ends_with(' ') {
786 result.push_str(line.trim_end_matches(' '));
787 result.push(' ');
788 } else {
789 result.push_str(line);
790 result.push('\n');
791 }
792 }
793 result
794}
795
796#[cfg(test)]
797mod tests {
798 use super::*;
799
800 #[test]
801 fn test_content_type_parse() {
802 let ct = ContentType::parse("text/plain; charset=utf-8");
803 assert_eq!(ct.type_, "text");
804 assert_eq!(ct.subtype, "plain");
805 assert_eq!(ct.charset(), Some("utf-8"));
806 }
807
808 #[test]
809 fn test_content_type_multipart() {
810 let ct = ContentType::parse("multipart/mixed; boundary=\"----=_Part_123\"");
811 assert!(ct.is_multipart());
812 assert_eq!(ct.boundary(), Some("----=_Part_123"));
813 }
814
815 #[test]
816 fn test_content_disposition() {
817 let cd = ContentDisposition::parse("attachment; filename=\"test.pdf\"");
818 assert!(cd.is_attachment());
819 assert_eq!(cd.filename(), Some("test.pdf"));
820 }
821
822 #[test]
823 fn test_content_disposition_inline() {
824 let cd = ContentDisposition::parse("inline");
825 assert!(!cd.is_attachment());
826 }
827
828 #[test]
829 fn test_base64_decode() {
830 let data = b"SGVsbG8gV29ybGQ=";
831 let decoded = decode_base64(data).unwrap();
832 assert_eq!(decoded, b"Hello World");
833 }
834
835 #[test]
836 fn test_base64_decode_with_newlines() {
837 let data = b"SGVs\nbG8g\nV29y\nbGQ=";
838 let decoded = decode_base64(data).unwrap();
839 assert_eq!(decoded, b"Hello World");
840 }
841
842 #[test]
843 fn test_quoted_printable_decode() {
844 let data = b"=48=C3=A5kan";
845 let decoded = decode_quoted_printable(data);
846 assert_eq!(std::str::from_utf8(&decoded).unwrap(), "Håkan");
847 }
848
849 #[test]
850 fn test_quoted_printable_soft_break() {
851 let data = b"line=\r\ncontinued";
852 let decoded = decode_quoted_printable(data);
853 assert_eq!(std::str::from_utf8(&decoded).unwrap(), "linecontinued");
854 }
855
856 #[test]
857 fn test_uuencode_simple() {
858 let data = b"begin 644 test.txt\n+5B5C(&%L9&%C\n`\nend\n";
859 let decoded = decode_uuencode(data);
860 assert!(decoded.is_some());
861 assert!(!decoded.unwrap().is_empty());
862 }
863
864 #[test]
865 fn test_parse_mime_info() {
866 let headers = vec![
867 ("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string()),
868 ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
869 ];
870 let mi = parse_mime_info(&headers).unwrap();
871 assert_eq!(mi.content_type.charset(), Some("iso-8859-1"));
872 assert_eq!(mi.content_transfer_encoding.as_deref(), Some("quoted-printable"));
873 }
874
875 #[test]
876 fn test_parse_mime_info_no_cte() {
877 let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
878 let mi = parse_mime_info(&headers).unwrap();
879 assert_eq!(mi.content_type.charset(), Some("utf-8"));
880 assert!(mi.content_transfer_encoding.is_none());
881 }
882
883 #[test]
884 fn test_parse_mime_info_no_ct() {
885 let headers: Vec<(String, String)> = vec![("from".to_string(), "a@b.com".to_string())];
886 assert!(parse_mime_info(&headers).is_none());
887 }
888
889 #[test]
890 fn test_decode_body_base64() {
891 let headers = vec![
892 ("content-type".to_string(), "text/plain; charset=utf-8".to_string()),
893 ("content-transfer-encoding".to_string(), "base64".to_string()),
894 ];
895 let mi = parse_mime_info(&headers).unwrap();
896 let body = b"SGVsbG8gV29ybGQ=";
897 let decoded = decode_body(body, &mi);
898 assert_eq!(decoded, "Hello World");
899 }
900
901 #[test]
902 fn test_decode_body_quoted_printable() {
903 let headers = vec![
904 ("content-type".to_string(), "text/plain; charset=utf-8".to_string()),
905 ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
906 ];
907 let mi = parse_mime_info(&headers).unwrap();
908 let body = b"Hello=20World=21";
909 let decoded = decode_body(body, &mi);
910 assert_eq!(decoded, "Hello World!");
911 }
912
913 #[test]
914 fn test_decode_body_7bit_passthrough() {
915 let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
916 let mi = parse_mime_info(&headers).unwrap();
917 let body = b"Hello World";
918 let decoded = decode_body(body, &mi);
919 assert_eq!(decoded, "Hello World");
920 }
921
922 #[test]
923 fn test_decode_body_charset_iso8859_1() {
924 let headers = vec![
925 ("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string()),
926 ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
927 ];
928 let mi = parse_mime_info(&headers).unwrap();
929 let body = b"H=E5kan";
931 let decoded = decode_body(body, &mi);
932 assert_eq!(decoded, "Håkan");
933 }
934
935 #[test]
936 fn test_process_mime_body_no_mime() {
937 let headers = vec![("from".to_string(), "a@b.com".to_string())];
938 let (body, charset) = process_mime_body(&headers, b"Hello World");
939 assert_eq!(body, "Hello World");
940 assert!(charset.is_none());
941 }
942
943 #[test]
944 fn test_process_mime_body_with_charset() {
945 let headers =
946 vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
947 let (body, charset) = process_mime_body(&headers, b"Hello");
948 assert_eq!(body, "Hello");
949 assert_eq!(charset.as_deref(), Some("iso-8859-1"));
950 }
951
952 #[test]
953 fn test_decode_body_iso_8859_7_kalimera() {
954 let headers =
955 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
956 let mi = parse_mime_info(&headers).unwrap();
957 let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
959 let decoded = decode_body(body, &mi);
960 assert_eq!(decoded, "Καλημερα");
961 }
962
963 #[test]
964 fn test_decode_body_windows_1253_kalimera() {
965 let headers =
966 vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
967 let mi = parse_mime_info(&headers).unwrap();
968 let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
970 let decoded = decode_body(body, &mi);
971 assert_eq!(decoded, "Καλημερα");
972 }
973
974 #[test]
975 fn test_decode_body_iso_8859_7_tonos() {
976 let headers =
977 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
978 let mi = parse_mime_info(&headers).unwrap();
979 let body = b"\xDC\xED\xE8\xF1\xF9\xF0\xEF\xF2";
981 let decoded = decode_body(body, &mi);
982 assert_eq!(decoded, "άνθρωπος");
983 }
984
985 #[test]
986 fn test_decode_body_windows_1253_tonos() {
987 let headers =
988 vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
989 let mi = parse_mime_info(&headers).unwrap();
990 let body = b"\xDC\xED\xE8\xF1\xF9\xF0\xEF\xF2";
992 let decoded = decode_body(body, &mi);
993 assert_eq!(decoded, "άνθρωπος");
994 }
995
996 #[test]
997 fn test_decode_body_no_charset_iso_8859_7_fallback() {
998 let headers = vec![("content-type".to_string(), "text/plain".to_string())];
999 let mi = parse_mime_info(&headers).unwrap();
1000 let body = b"\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1";
1002 let decoded = decode_body(body, &mi);
1003 assert_eq!(decoded, "Καλημερα");
1004 }
1005
1006 #[test]
1007 fn test_decode_body_iso_8859_7_quoted_printable() {
1008 let headers = vec![
1009 ("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string()),
1010 ("content-transfer-encoding".to_string(), "quoted-printable".to_string()),
1011 ];
1012 let mi = parse_mime_info(&headers).unwrap();
1013 let body = b"\xCA=E1=EB=E7=EC=E5=F1=E1";
1015 let decoded = decode_body(body, &mi);
1016 assert_eq!(decoded, "Καλημερα");
1017 }
1018
1019 #[test]
1020 fn test_decode_body_iso_8859_7_base64() {
1021 let headers = vec![
1022 ("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string()),
1023 ("content-transfer-encoding".to_string(), "base64".to_string()),
1024 ];
1025 let mi = parse_mime_info(&headers).unwrap();
1026 let body = b"yuHr5+zl8eE=";
1028 let decoded = decode_body(body, &mi);
1029 assert_eq!(decoded, "Καλημερα");
1030 }
1031
1032 #[test]
1033 fn test_find_multipart_charset_second_part() {
1034 let boundary = "----=_NextPart_000_1234";
1036 let body = "------=_NextPart_000_1234\n\
1037 Content-Type: text/plain; format=flowed\n\
1038 \n\
1039 Some plain text\n\
1040 \n\
1041 ------=_NextPart_000_1234\n\
1042 Content-Type: text/html; charset=\"iso-8859-7\"\n\
1043 \n\
1044 <p>Some text</p>\n\
1045 \n\
1046 ------=_NextPart_000_1234--\n"
1047 .to_string();
1048 let result = find_multipart_charset(body.as_bytes(), boundary);
1049 assert_eq!(result.as_deref(), Some("iso-8859-7"));
1050 }
1051
1052 #[test]
1053 fn test_find_multipart_charset_all_parts_no_charset() {
1054 let boundary = "----=_NextPart_000_5678";
1056 let body = "------=_NextPart_000_5678\n\
1057 Content-Type: text/plain; format=flowed\n\
1058 \n\
1059 First part\n\
1060 \n\
1061 ------=_NextPart_000_5678\n\
1062 Content-Type: text/plain\n\
1063 \n\
1064 Second part\n\
1065 \n\
1066 ------=_NextPart_000_5678--\n"
1067 .to_string();
1068 let result = find_multipart_charset(body.as_bytes(), boundary);
1069 assert!(result.is_none());
1070 }
1071
1072 #[test]
1073 fn test_process_mime_body_multipart_charset_in_second_part() {
1074 let headers = vec![(
1075 "content-type".to_string(),
1076 "multipart/mixed; boundary=\"----=_NextPart_000_9999\"".to_string(),
1077 )];
1078 let body = b"------=_NextPart_000_9999\n\
1079 Content-Type: text/plain; format=flowed\n\
1080 Content-Transfer-Encoding: 8bit\n\
1081 \n\
1082 Hello\n\
1083 \n\
1084 ------=_NextPart_000_9999\n\
1085 Content-Type: text/html; charset=\"iso-8859-7\"\n\
1086 Content-Transfer-Encoding: 8bit\n\
1087 \n\
1088 \xCB\xE1\xEC\xE7\xED\xE5\xF1\xE1\n\
1089 \n\
1090 ------=_NextPart_000_9999--\n";
1091 let charset = resolve_charset(body, &parse_mime_info(&headers).unwrap());
1092 assert_eq!(
1093 charset.as_deref(),
1094 Some("iso-8859-7"),
1095 "Should detect charset from second part when first part lacks it"
1096 );
1097 }
1098
1099 #[test]
1100 fn test_decode_body_greek_utf8() {
1101 let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
1102 let mi = parse_mime_info(&headers).unwrap();
1103 let body = "Καλημερα".as_bytes();
1104 let decoded = decode_body(body, &mi);
1105 assert_eq!(decoded, "Καλημερα");
1106 }
1107
1108 #[test]
1109 fn test_process_mime_body_no_ct_greek_fallback() {
1110 let headers = vec![("from".to_string(), "a@b.com".to_string())];
1112 let body = b"\xC3\xE5\xE9\xE1";
1114 let (decoded, charset) = process_mime_body(&headers, body);
1115 assert!(!decoded.contains('\u{FFFD}'), "Should decode Greek without replacement chars");
1116 assert!(charset.is_some(), "Should report a detected charset");
1118 assert_eq!(decoded, "Γεια");
1119 }
1120
1121 #[test]
1124 fn test_decode_body_uppercase_tonos_iso_8859_7() {
1125 let headers =
1128 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1129 let mi = parse_mime_info(&headers).unwrap();
1130 let body = b"\xB6\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1131 let decoded = decode_body(body, &mi);
1132 assert_eq!(decoded, "Άνθρωπος");
1133 }
1134
1135 #[test]
1136 fn test_decode_body_uppercase_tonos_windows_1253() {
1137 let headers =
1140 vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1141 let mi = parse_mime_info(&headers).unwrap();
1142 let body = b"\xA2\xED\xE8\xF1\xF9\xF0\xEF\xF2";
1143 let decoded = decode_body(body, &mi);
1144 assert_eq!(decoded, "Άνθρωπος");
1145 }
1146
1147 #[test]
1148 fn test_decode_body_real_world_greek_phrase() {
1149 let headers =
1152 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1153 let mi = parse_mime_info(&headers).unwrap();
1154 let body = b"\xCA\xE1\xEB\xFC\x20\xE1\xF0\xFC\xE3\xE5\xF5\xEC\xE1";
1155 let decoded = decode_body(body, &mi);
1156 assert_eq!(decoded, "Καλό απόγευμα");
1157 }
1158
1159 #[test]
1160 fn test_decode_body_mixed_greek_latin() {
1161 let headers = vec![("content-type".to_string(), "text/plain; charset=utf-8".to_string())];
1164 let mi = parse_mime_info(&headers).unwrap();
1165 let body = "Hello Κόσμε!".as_bytes();
1166 let decoded = decode_body(body, &mi);
1167 assert_eq!(decoded, "Hello Κόσμε!");
1168 }
1169
1170 #[test]
1171 fn test_decode_body_question_marks_greek() {
1172 let headers =
1176 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1177 let mi = parse_mime_info(&headers).unwrap();
1178 let body = b"\xD0\xFE\xF2\x20\xE5\xDF\xF3\xE1\xE9;";
1179 let decoded = decode_body(body, &mi);
1180 assert_eq!(decoded, "Πώς είσαι;");
1181 }
1182
1183 #[test]
1184 fn test_find_multipart_charset_mixed_encodings() {
1185 let boundary = "----=_Part_123";
1187 let body = "------=_Part_123\n\
1188 Content-Type: text/plain\n\
1189 \n\
1190 English text\n\
1191 \n\
1192 ------=_Part_123\n\
1193 Content-Type: text/html; charset=\"iso-8859-7\"\n\
1194 \n\
1195 <p>Greek text</p>\n\
1196 \n\
1197 ------=_Part_123--\n"
1198 .to_string();
1199 let result = find_multipart_charset(body.as_bytes(), boundary);
1200 assert_eq!(
1201 result.as_deref(),
1202 Some("iso-8859-7"),
1203 "Should find charset from second part even when first part has none"
1204 );
1205 }
1206
1207 #[test]
1208 fn test_process_mime_body_multipart_with_greek_html() {
1209 let headers = vec![(
1211 "content-type".to_string(),
1212 "multipart/alternative; boundary=\"----=_NextPart_000_1111\"".to_string(),
1213 )];
1214 let body = b"------=_NextPart_000_1111\n\
1215 Content-Type: text/plain; charset=\"iso-8859-7\"\n\
1216 \n\
1217 \xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1\n\
1218 \n\
1219 ------=_NextPart_000_1111\n\
1220 Content-Type: text/html; charset=\"iso-8859-7\"\n\
1221 \n\
1222 <html><body>\xCA\xE1\xEB\xE7\xEC\xE5\xF1\xE1</body></html>\n\
1223 \n\
1224 ------=_NextPart_000_1111--\n";
1225 let (decoded, charset) = process_mime_body(&headers, body);
1226 assert_eq!(charset.as_deref(), Some("iso-8859-7"));
1227 assert!(decoded.contains("Καλημερα"), "Should contain decoded Greek text");
1229 assert!(!decoded.contains('\u{FFFD}'), "Should not have replacement characters");
1230 }
1231
1232 #[test]
1233 fn test_decode_body_all_greek_letters_iso_8859_7() {
1234 let headers =
1237 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1238 let mi = parse_mime_info(&headers).unwrap();
1239 let body = b"\xE1\xE2\xE3\xE4\xE5";
1240 let decoded = decode_body(body, &mi);
1241 assert_eq!(decoded, "αβγδε");
1242 }
1243
1244 #[test]
1245 fn test_decode_body_all_greek_letters_windows_1253() {
1246 let headers =
1249 vec![("content-type".to_string(), "text/plain; charset=windows-1253".to_string())];
1250 let mi = parse_mime_info(&headers).unwrap();
1251 let body = b"\xC1\xC2\xC3\xC4\xC5";
1252 let decoded = decode_body(body, &mi);
1253 assert_eq!(decoded, "ΑΒΓΔΕ");
1254 }
1255
1256 #[test]
1257 fn test_decode_body_diaeresis_greek() {
1258 let headers =
1261 vec![("content-type".to_string(), "text/plain; charset=iso-8859-7".to_string())];
1262 let mi = parse_mime_info(&headers).unwrap();
1263 let body = b"\xFA\xE4\xE9\xEF\xF2";
1264 let decoded = decode_body(body, &mi);
1265 assert_eq!(decoded, "ϊδιος");
1266 }
1267
1268 #[test]
1269 fn test_multipart_inline_image() {
1270 let headers = vec![(
1272 "content-type".to_string(),
1273 "multipart/mixed; boundary=\"----=_Part_123\"".to_string(),
1274 )];
1275
1276 let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1278
1279 let body = format!(
1280 "------=_Part_123\n\
1281 Content-Type: text/plain; charset=utf-8\n\
1282 \n\
1283 Hello world\n\
1284 \n\
1285 ------=_Part_123\n\
1286 Content-Type: image/gif; name=\"pixel.gif\"\n\
1287 Content-Disposition: inline; filename=\"pixel.gif\"\n\
1288 Content-Transfer-Encoding: base64\n\
1289 \n\
1290 {}\n\
1291 \n\
1292 ------=_Part_123--\n",
1293 std::str::from_utf8(gif_bytes).unwrap()
1294 );
1295
1296 let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1297
1298 assert!(decoded.contains("Hello world"), "Should contain text content");
1300
1301 assert!(
1303 decoded.contains("[INLINE_IMAGE:image/gif:"),
1304 "Should contain inline image marker"
1305 );
1306 assert!(
1307 decoded.contains("R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7"),
1308 "Should contain base64 data in marker"
1309 );
1310
1311 assert!(!decoded.contains("------=_Part_123"), "Should not contain MIME boundaries");
1313 }
1314
1315 #[test]
1316 fn test_multipart_attachment_image() {
1317 let headers = vec![(
1320 "content-type".to_string(),
1321 "multipart/mixed; boundary=\"----=_Part_456\"".to_string(),
1322 )];
1323
1324 let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1325
1326 let body = format!(
1327 "------=_Part_456\n\
1328 Content-Type: text/plain; charset=utf-8\n\
1329 \n\
1330 See attached image\n\
1331 \n\
1332 ------=_Part_456\n\
1333 Content-Type: image/gif; name=\"chart.gif\"\n\
1334 Content-Disposition: attachment; filename=\"chart.gif\"\n\
1335 Content-Transfer-Encoding: base64\n\
1336 \n\
1337 {}\n\
1338 \n\
1339 ------=_Part_456--\n",
1340 std::str::from_utf8(gif_bytes).unwrap()
1341 );
1342
1343 let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1344
1345 assert!(decoded.contains("See attached image"), "Should contain text content");
1347
1348 assert!(
1350 decoded.contains("[INLINE_IMAGE:image/gif:"),
1351 "Should embed image inline even when Content-Disposition is attachment"
1352 );
1353 assert!(
1354 !decoded.contains("[Attachment: chart.gif]"),
1355 "Should NOT show attachment notation for images"
1356 );
1357 }
1358
1359 #[test]
1360 fn test_multipart_image_with_content_id() {
1361 let headers = vec![(
1363 "content-type".to_string(),
1364 "multipart/related; boundary=\"----=_Part_789\"".to_string(),
1365 )];
1366
1367 let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1368
1369 let body = format!(
1370 "------=_Part_789\n\
1371 Content-Type: text/html; charset=utf-8\n\
1372 \n\
1373 <html><body>Logo: <img src=\"cid:logo@example.com\"></body></html>\n\
1374 \n\
1375 ------=_Part_789\n\
1376 Content-Type: image/gif; name=\"logo.gif\"\n\
1377 Content-ID: <logo@example.com>\n\
1378 Content-Transfer-Encoding: base64\n\
1379 \n\
1380 {}\n\
1381 \n\
1382 ------=_Part_789--\n",
1383 std::str::from_utf8(gif_bytes).unwrap()
1384 );
1385
1386 let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1387
1388 assert!(decoded.contains("<html>"), "Should contain HTML content");
1390
1391 assert!(
1393 decoded.contains("[INLINE_IMAGE:image/gif:"),
1394 "Should contain inline image marker for Content-ID image"
1395 );
1396 }
1397
1398 #[test]
1399 fn test_multipart_pdf_attachment() {
1400 let headers = vec![(
1402 "content-type".to_string(),
1403 "multipart/mixed; boundary=\"----=_Part_PDF\"".to_string(),
1404 )];
1405
1406 let pdf_bytes = b"JVBERi0xLjQKJeLjz9M="; let body = format!(
1409 "------=_Part_PDF\n\
1410 Content-Type: text/plain; charset=utf-8\n\
1411 \n\
1412 Please review the attached document.\n\
1413 \n\
1414 ------=_Part_PDF\n\
1415 Content-Type: application/pdf; name=\"report.pdf\"\n\
1416 Content-Disposition: attachment; filename=\"report.pdf\"\n\
1417 Content-Transfer-Encoding: base64\n\
1418 \n\
1419 {}\n\
1420 \n\
1421 ------=_Part_PDF--\n",
1422 std::str::from_utf8(pdf_bytes).unwrap()
1423 );
1424
1425 let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1426
1427 assert!(decoded.contains("Please review"), "Should contain text content");
1429
1430 assert!(
1432 decoded.contains("[Attachment: report.pdf]"),
1433 "Should show PDF attachment notation"
1434 );
1435
1436 assert!(!decoded.contains("JVBERi0xLjQKJeLjz9M="), "Should not contain raw PDF base64");
1438 assert!(!decoded.contains("application/pdf"), "Should not show content-type in output");
1439 }
1440
1441 #[test]
1442 fn test_multipart_mixed_inline_and_attachment() {
1443 let headers = vec![(
1445 "content-type".to_string(),
1446 "multipart/mixed; boundary=\"----=_Part_MIX\"".to_string(),
1447 )];
1448
1449 let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1450
1451 let body = format!(
1452 "------=_Part_MIX\n\
1453 Content-Type: text/plain; charset=utf-8\n\
1454 \n\
1455 Email body text\n\
1456 \n\
1457 ------=_Part_MIX\n\
1458 Content-Type: image/gif; name=\"inline.gif\"\n\
1459 Content-Disposition: inline; filename=\"inline.gif\"\n\
1460 Content-Transfer-Encoding: base64\n\
1461 \n\
1462 {}\n\
1463 \n\
1464 ------=_Part_MIX\n\
1465 Content-Type: image/jpeg; name=\"photo.jpg\"\n\
1466 Content-Disposition: attachment; filename=\"photo.jpg\"\n\
1467 Content-Transfer-Encoding: base64\n\
1468 \n\
1469 {}\n\
1470 \n\
1471 ------=_Part_MIX--\n",
1472 std::str::from_utf8(gif_bytes).unwrap(),
1473 std::str::from_utf8(gif_bytes).unwrap()
1474 );
1475
1476 let (decoded, _charset) = process_mime_body(&headers, body.as_bytes());
1477
1478 assert!(decoded.contains("Email body text"), "Should contain text content");
1480
1481 let inline_count = decoded.matches("[INLINE_IMAGE:").count();
1483 assert_eq!(inline_count, 2, "Both images (inline + attachment) should be embedded");
1484
1485 assert!(!decoded.contains("[Attachment: "), "No image should remain as attachment link");
1486 }
1487
1488 #[test]
1489 fn test_multipart_greek_text_with_inline_image() {
1490 let headers = vec![(
1492 "content-type".to_string(),
1493 "multipart/mixed; boundary=\"----=_Part_GR\"".to_string(),
1494 )];
1495
1496 let greek_text = vec![0xC3u8, 0xE5, 0xE9, 0xE1, 0x20, 0xF3, 0xEF, 0xF5];
1498 let gif_bytes = b"R0lGODlhAQABAIAAAP8AAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7";
1499
1500 let mut body =
1501 b"------=_Part_GR\nContent-Type: text/plain; charset=iso-8859-7\n\n".to_vec();
1502 body.extend_from_slice(&greek_text);
1503 body.extend_from_slice(b"\n\n------=_Part_GR\n");
1504 body.extend_from_slice(b"Content-Type: image/gif; name=\"icon.gif\"\n");
1505 body.extend_from_slice(b"Content-Disposition: inline; filename=\"icon.gif\"\n");
1506 body.extend_from_slice(b"Content-Transfer-Encoding: base64\n\n");
1507 body.extend_from_slice(gif_bytes);
1508 body.extend_from_slice(b"\n\n------=_Part_GR--\n");
1509
1510 let (decoded, charset) = process_mime_body(&headers, &body);
1511
1512 assert_eq!(charset.as_deref(), Some("iso-8859-7"), "Should detect Greek charset");
1514
1515 assert!(decoded.contains("Γεια σου"), "Should contain decoded Greek text");
1517
1518 assert!(
1520 decoded.contains("[INLINE_IMAGE:image/gif:"),
1521 "Should contain inline image marker"
1522 );
1523
1524 assert!(!decoded.contains('\u{FFFD}'), "Should not have replacement characters");
1526 }
1527
1528 #[test]
1529 fn test_decode_body_mislabeled_iso_8859_1_as_greek() {
1530 let headers =
1534 vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
1535 let mi = parse_mime_info(&headers).unwrap();
1536 let body = b"\xD3\xF9\xF3\xF4\xDC\x20\xFC\xEB\xE1\x20\xE1\xF5\xF4\xDC";
1537
1538 let decoded = decode_body(body, &mi);
1539
1540 assert!(
1542 decoded.contains("Σωστά") || decoded.contains("ωστά"),
1543 "Should detect Greek in mislabeled iso-8859-1 body: got '{}'",
1544 decoded
1545 );
1546
1547 assert!(!decoded.contains("ÓùóôÜ"), "Should not have mojibake: got '{}'", decoded);
1549 }
1550
1551 #[test]
1552 fn test_rfc2231_continuation_filename() {
1553 let headers = vec![(
1554 "content-disposition".to_string(),
1555 "attachment; filename*0=\"very_long_\"; filename*1=\"filename.pdf\"".to_string(),
1556 )];
1557 assert_eq!(extract_filename(&headers), Some("very_long_filename.pdf".to_string()));
1558 }
1559
1560 #[test]
1561 fn test_rfc2231_encoded_filename() {
1562 let headers = vec![(
1563 "content-disposition".to_string(),
1564 "attachment; filename*=utf-8''%C3%A9tude.pdf".to_string(),
1565 )];
1566 assert_eq!(extract_filename(&headers), Some("étude.pdf".to_string()));
1567 }
1568
1569 #[test]
1570 fn test_format_flowed_unwrap() {
1571 let input = "This is a long \nline that was wrapped.\n\nNew paragraph.\n";
1572 let expected = "This is a long line that was wrapped.\n\nNew paragraph.\n";
1573 assert_eq!(unflow_text(input), expected);
1574 }
1575
1576 #[test]
1577 fn test_format_flowed_signature_not_unwrapped() {
1578 let input = "Hello \nworld.\n-- \nSignature\n";
1579 let expected = "Hello world.\n-- \nSignature\n";
1580 assert_eq!(unflow_text(input), expected);
1581 }
1582
1583 #[test]
1584 fn test_decode_body_correct_iso_8859_1_latin_preserved() {
1585 let headers =
1588 vec![("content-type".to_string(), "text/plain; charset=iso-8859-1".to_string())];
1589 let mi = parse_mime_info(&headers).unwrap();
1590 let body = b"Caf\xE9 r\xE9sum\xE9";
1591
1592 let decoded = decode_body(body, &mi);
1593
1594 assert_eq!(
1596 decoded, "Café résumé",
1597 "Should preserve correct Latin-1 text: got '{}'",
1598 decoded
1599 );
1600 }
1601
1602 #[test]
1603 fn test_content_type_is_text() {
1604 let ct = ContentType::parse("text/html; charset=utf-8");
1605 assert!(ct.is_text());
1606 assert!(!ct.is_multipart());
1607 }
1608
1609 #[test]
1610 fn test_content_type_full_type() {
1611 let ct = ContentType::parse("application/pdf");
1612 assert_eq!(ct.full_type(), "application/pdf");
1613 }
1614
1615 #[test]
1616 fn test_content_type_name_param() {
1617 let ct = ContentType::parse("image/jpeg; name=\"photo.jpg\"");
1618 assert_eq!(ct.name(), Some("photo.jpg"));
1619 }
1620
1621 #[test]
1622 fn test_content_type_no_subtype() {
1623 let ct = ContentType::parse("text");
1624 assert_eq!(ct.type_, "text");
1625 assert_eq!(ct.subtype, "");
1626 }
1627
1628 #[test]
1629 fn test_content_disposition_no_filename() {
1630 let cd = ContentDisposition::parse("inline");
1631 assert_eq!(cd.filename(), None);
1632 assert!(!cd.is_attachment());
1633 }
1634
1635 #[test]
1636 fn test_decode_quoted_printable_underscore_as_space() {
1637 let data = b"Hello_World";
1638 let decoded = decode_quoted_printable(data);
1639 assert_eq!(std::str::from_utf8(&decoded).unwrap(), "Hello World");
1640 }
1641
1642 #[test]
1643 fn test_decode_uuencode_no_begin() {
1644 let data = b"not a uuencoded block";
1645 let result = decode_uuencode(data);
1646 assert!(result.is_none());
1647 }
1648
1649 #[test]
1650 fn test_unflow_text_no_trailing_space() {
1651 let input = "Line one.\nLine two.\n";
1652 let result = unflow_text(input);
1653 assert_eq!(result, "Line one.\nLine two.\n");
1654 }
1655
1656 #[test]
1657 fn test_process_mime_body_format_flowed() {
1658 let headers = vec![(
1659 "content-type".to_string(),
1660 "text/plain; charset=utf-8; format=flowed".to_string(),
1661 )];
1662 let body = b"This is a long \nline that flows.\n";
1663 let (decoded, _) = process_mime_body(&headers, body);
1664 assert!(decoded.contains("This is a long line that flows."), "got: {}", decoded);
1665 }
1666}