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;
8use crate::encoders::encode::rfc2047_encode;
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(
50        &self,
51        mut output: impl std::io::Write,
52        mut bytes_written: usize,
53    ) -> std::io::Result<usize> {
54        output.write_all(self.c_type.as_bytes())?;
55        bytes_written += self.c_type.len();
56        if !self.attributes.is_empty() {
57            output.write_all(b"; ")?;
58            bytes_written += 2;
59            for (pos, (key, value)) in self.attributes.iter().enumerate() {
60                if bytes_written + key.len() + value.len() + 3 >= 76 {
61                    output.write_all(b"\r\n\t")?;
62                    bytes_written = 1;
63                }
64
65                output.write_all(key.as_bytes())?;
66                output.write_all(b"=")?;
67                bytes_written += rfc2047_encode(value, &mut output)? + key.len() + 1;
68                if pos < self.attributes.len() - 1 {
69                    output.write_all(b"; ")?;
70                    bytes_written += 2;
71                }
72            }
73        }
74        output.write_all(b"\r\n")?;
75        Ok(0)
76    }
77}
78
79#[cfg(test)]
80mod tests {
81    use super::*;
82
83    fn build(content_type: ContentType<'_>) -> String {
84        let mut output = Vec::new();
85        content_type.write_header(&mut output, 14).unwrap();
86        String::from_utf8(output).unwrap()
87    }
88
89    #[test]
90    fn encoded_parameter_value_stays_quoted() {
91        let header =
92            build(ContentType::new("attachment").attribute("filename", "Jahresabschluß, 2024.pdf"));
93        assert!(header.contains("filename=\"=?"), "{header:?}");
94        assert!(header.contains("?=\""), "{header:?}");
95    }
96
97    #[test]
98    fn plain_parameter_value_is_quoted_and_escaped() {
99        let header =
100            build(ContentType::new("attachment").attribute("filename", "report \"final\".pdf"));
101        assert!(header.contains(r#""report \"final\".pdf""#), "{header:?}");
102    }
103}