Skip to main content

mail_builder/headers/
content_type.rs

1/*
2 * SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <hello@stalw.art>
3 *
4 * SPDX-License-Identifier: Apache-2.0 OR MIT
5 */
6
7use super::{Header, fold::FoldWriter, rfc2047::write_parameter};
8use crate::writer::Writer;
9use std::borrow::Cow;
10
11/// MIME Content-Type or Content-Disposition header
12#[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    /// Create a new Content-Type or Content-Disposition header
20    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    /// Set a Content-Type / Content-Disposition attribute
28    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    /// Returns true when the part is text/*
38    pub fn is_text(&self) -> bool {
39        self.c_type.starts_with("text/")
40    }
41
42    /// Returns true when the part is an attachment
43    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}