mail_builder/headers/
content_type.rs1use super::{Header, fold::FoldWriter, rfc2047::write_parameter};
8use crate::writer::Writer;
9use std::borrow::Cow;
10
11#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
13pub struct ContentType<'x> {
14 pub c_type: Cow<'x, str>,
15 pub attributes: Vec<(Cow<'x, str>, Cow<'x, str>)>,
16}
17
18impl<'x> ContentType<'x> {
19 pub fn new(c_type: impl Into<Cow<'x, str>>) -> Self {
21 Self {
22 c_type: c_type.into(),
23 attributes: Vec::new(),
24 }
25 }
26
27 pub fn attribute(
29 mut self,
30 key: impl Into<Cow<'x, str>>,
31 value: impl Into<Cow<'x, str>>,
32 ) -> Self {
33 self.attributes.push((key.into(), value.into()));
34 self
35 }
36
37 pub fn is_text(&self) -> bool {
39 self.c_type.starts_with("text/")
40 }
41
42 pub fn is_attachment(&self) -> bool {
44 self.c_type == "attachment"
45 }
46}
47
48impl Header for ContentType<'_> {
49 fn write_header(&self, output: &mut impl Writer, column: usize) {
50 let mut folder = FoldWriter::new(output, column);
51 folder.write(self.c_type.as_bytes());
52
53 if let Some((last, head)) = self.attributes.split_last() {
54 for (key, value) in head {
55 folder.semicolon();
56 write_parameter(&mut folder, key, value, 1);
57 }
58 folder.semicolon();
59 write_parameter(&mut folder, &last.0, &last.1, 0);
60 }
61
62 folder.finish();
63 }
64}
65
66#[cfg(test)]
67mod tests {
68 use super::*;
69
70 fn build(content_type: ContentType<'_>) -> String {
71 let mut output = Vec::new();
72 content_type.write_header(&mut output, 14);
73 String::from_utf8(output).unwrap()
74 }
75
76 fn parsed_filename(header: &str) -> String {
77 use mail_parser::MimeHeaders;
78
79 let raw = format!("Content-Type: {header}");
80 let message = mail_parser::MessageParser::new()
81 .parse_headers(raw.as_bytes())
82 .unwrap();
83 message
84 .content_type()
85 .and_then(|value| value.attribute("filename"))
86 .unwrap()
87 .to_string()
88 }
89
90 #[test]
91 fn non_ascii_parameter_value_uses_rfc_2231() {
92 let header =
93 build(ContentType::new("attachment").attribute("filename", "Jahresabschluß, 2024.pdf"));
94 assert_eq!(
95 header,
96 "attachment; filename*=UTF-8''Jahresabschlu%C3%9F%2C%202024.pdf\r\n"
97 );
98 assert_eq!(parsed_filename(&header), "Jahresabschluß, 2024.pdf");
99 }
100
101 #[test]
102 fn long_rfc_2231_value_is_split_into_sections() {
103 let name = "Réunion d'équipe: résumé des décisions du trimestre et prochaines étapes du projet (version finale).pdf";
104 let header = build(
105 ContentType::new("attachment")
106 .attribute("filename", name)
107 .attribute("size", "1234"),
108 );
109 assert!(
110 header.contains("filename*0*=UTF-8''R%C3%A9union"),
111 "{header:?}"
112 );
113 assert!(header.contains(" filename*1*="), "{header:?}");
114 assert!(!header.contains("%C3;"), "{header:?}");
115 assert!(header.ends_with(" size=\"1234\"\r\n"), "{header:?}");
116 assert!(!header.contains("=?"), "{header:?}");
117 for line in header.trim_end().split("\r\n") {
118 assert!(line.len() <= 78, "{line:?}");
119 }
120 assert_eq!(parsed_filename(&header), name);
121 }
122
123 #[test]
124 fn control_characters_in_parameter_values_are_percent_encoded() {
125 let header = build(ContentType::new("attachment").attribute("filename", "a\r\nb\u{1}.pdf"));
126 assert_eq!(header, "attachment; filename*=UTF-8''a%0D%0Ab%01.pdf\r\n");
127 }
128
129 #[test]
130 fn plain_parameter_value_is_quoted_and_escaped() {
131 let header =
132 build(ContentType::new("attachment").attribute("filename", "report \"final\".pdf"));
133 assert!(header.contains(r#""report \"final\".pdf""#), "{header:?}");
134 }
135}