Skip to main content

email_encoding/headers/
rfc2231.rs

1//! [RFC 2231] encoder.
2//!
3//! [RFC 2231]: https://datatracker.ietf.org/doc/html/rfc2231
4
5use core::fmt::{self, Write};
6
7use super::{MAX_LINE_LEN, hex_encoding, utils, writer::EmailWriter};
8
9/// Encode a string via RFC 2231.
10///
11/// # Examples
12///
13/// ```rust
14/// # use email_encoding::headers::writer::EmailWriter;
15/// # fn main() -> core::fmt::Result {
16/// {
17///     let input = "invoice.pdf";
18///
19///     let mut output = String::new();
20///     {
21///         let mut writer = EmailWriter::new(&mut output, 0, 0, false);
22///         email_encoding::headers::rfc2231::encode("filename", input, &mut writer)?;
23///     }
24///     assert_eq!(output, "filename=\"invoice.pdf\"");
25/// }
26///
27/// {
28///     let input = "invoice_2022_06_04_letshaveaverylongfilenamewhynotemailcanhandleit.pdf";
29///
30///     let mut output = String::new();
31///     {
32///         let mut writer = EmailWriter::new(&mut output, 0, 0, false);
33///         email_encoding::headers::rfc2231::encode("filename", input, &mut writer)?;
34///     }
35///     assert_eq!(
36///         output,
37///         concat!(
38///             "\r\n",
39///             " filename*0=\"invoice_2022_06_04_letshaveaverylongfilenamewhynotemailcanha\";\r\n",
40///             " filename*1=\"ndleit.pdf\""
41///         )
42///     );
43/// }
44///
45/// {
46///     let input = "faktΓΊra.pdf";
47///
48///     let mut output = String::new();
49///     {
50///         let mut writer = EmailWriter::new(&mut output, 0, 0, false);
51///         email_encoding::headers::rfc2231::encode("filename", input, &mut writer)?;
52///     }
53///     assert_eq!(
54///         output,
55///         concat!(
56///             "\r\n",
57///             " filename*0*=utf-8''fakt%C3%BAra.pdf"
58///         )
59///     );
60/// }
61/// # Ok(())
62/// # }
63/// ```
64pub fn encode(key: &str, mut value: &str, w: &mut EmailWriter<'_>) -> fmt::Result {
65    assert!(
66        utils::str_is_ascii_alphanumeric(key),
67        "`key` must only be composed of ascii alphanumeric chars"
68    );
69    assert!(
70        key.len() + "*12*=utf-8'';".len() < MAX_LINE_LEN,
71        "`key` must not be too long to cause the encoder to overflow the max line length"
72    );
73
74    if utils::str_is_ascii_printable(value) {
75        // Can be written normally (Parameter Value Continuations)
76
77        let quoted_plain_combined_len = key.len() + "=\"".len() + value.len() + "\"\r\n".len();
78        if w.line_len() + quoted_plain_combined_len <= MAX_LINE_LEN {
79            // Fits line
80
81            w.write_str(key)?;
82
83            w.write_char('=')?;
84
85            w.write_char('"')?;
86            utils::write_escaped(value, w)?;
87            w.write_char('"')?;
88        } else {
89            // Doesn't fit line
90
91            w.new_line()?;
92            w.forget_spaces();
93
94            let mut i = 0_usize;
95            loop {
96                write!(w, " {}*{}=\"", key, i)?;
97
98                let remaining_len = MAX_LINE_LEN - w.line_len() - "\"\r\n".len();
99
100                let value_ =
101                    utils::truncate_to_char_boundary(value, remaining_len.min(value.len()));
102                value = &value[value_.len()..];
103
104                utils::write_escaped(value_, w)?;
105
106                w.write_char('"')?;
107
108                if value.is_empty() {
109                    // End of value
110                    break;
111                }
112
113                // End of line
114                w.write_char(';')?;
115                w.new_line()?;
116
117                i += 1;
118            }
119        }
120    } else {
121        // Needs encoding (Parameter Value Character Set and Language Information)
122
123        w.new_line()?;
124        w.forget_spaces();
125
126        let mut i = 0_usize;
127        loop {
128            write!(w, " {}*{}*=", key, i)?;
129
130            if i == 0 {
131                w.write_str("utf-8''")?;
132            }
133
134            let mut chars = value.chars();
135            while w.line_len() < MAX_LINE_LEN - "=xx=xx=xx=xx;\r\n".len() {
136                match chars.next() {
137                    Some(c) => {
138                        hex_encoding::percent_encode_char(w, c)?;
139                        value = chars.as_str();
140                    }
141                    None => {
142                        break;
143                    }
144                }
145            }
146
147            if value.is_empty() {
148                // End of value
149                break;
150            }
151
152            // End of line
153            w.write_char(';')?;
154            w.new_line()?;
155
156            i += 1;
157        }
158    }
159
160    Ok(())
161}
162
163#[cfg(test)]
164mod tests {
165    use alloc::{borrow::ToOwned, string::String};
166
167    use pretty_assertions::assert_eq;
168
169    use super::*;
170
171    #[test]
172    fn empty() {
173        let mut s = "Content-Disposition: attachment;".to_owned();
174        let line_len = 1;
175
176        {
177            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
178            w.space();
179            encode("filename", "", &mut w).unwrap();
180        }
181
182        assert_eq!(s, "Content-Disposition: attachment; filename=\"\"");
183    }
184
185    #[test]
186    fn parameter() {
187        let mut s = "Content-Disposition: attachment;".to_owned();
188        let line_len = 1;
189
190        {
191            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
192            w.space();
193            encode("filename", "duck.txt", &mut w).unwrap();
194        }
195
196        assert_eq!(s, "Content-Disposition: attachment; filename=\"duck.txt\"");
197    }
198
199    #[test]
200    fn parameter_to_escape() {
201        let mut s = "Content-Disposition: attachment;".to_owned();
202        let line_len = 1;
203
204        {
205            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
206            w.space();
207            encode("filename", "du\"ck\\.txt", &mut w).unwrap();
208        }
209
210        assert_eq!(
211            s,
212            "Content-Disposition: attachment; filename=\"du\\\"ck\\\\.txt\""
213        );
214    }
215
216    #[test]
217    fn parameter_long() {
218        let mut s = "Content-Disposition: attachment;".to_owned();
219        let line_len = s.len();
220
221        {
222            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
223            w.space();
224            encode(
225                "filename",
226                "a-fairly-long-filename-just-to-see-what-happens-when-we-encode-it-will-the-client-be-able-to-handle-it.txt",
227                &mut w,
228            )
229            .unwrap();
230        }
231
232        assert_eq!(
233            s,
234            concat!(
235                "Content-Disposition: attachment;\r\n",
236                " filename*0=\"a-fairly-long-filename-just-to-see-what-happens-when-we-enco\";\r\n",
237                " filename*1=\"de-it-will-the-client-be-able-to-handle-it.txt\""
238            )
239        );
240    }
241
242    #[test]
243    fn parameter_special() {
244        let mut s = "Content-Disposition: attachment;".to_owned();
245        let line_len = s.len();
246
247        {
248            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
249            w.space();
250            encode("filename", "caffè.txt", &mut w).unwrap();
251        }
252
253        assert_eq!(
254            s,
255            concat!(
256                "Content-Disposition: attachment;\r\n",
257                " filename*0*=utf-8''caff%C3%A8.txt"
258            )
259        );
260    }
261
262    #[test]
263    fn parameter_special_long() {
264        let mut s = "Content-Disposition: attachment;".to_owned();
265        let line_len = s.len();
266
267        {
268            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
269            w.space();
270            encode(
271                "filename",
272                "testing-to-see-what-happens-when-πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•-are-placed-on-the-boundary.txt",
273                &mut w,
274            )
275            .unwrap();
276        }
277
278        assert_eq!(
279            s,
280            concat!(
281                "Content-Disposition: attachment;\r\n",
282                " filename*0*=utf-8''testing-to-see-what-happens-when-%F0%9F%93%95;\r\n",
283                " filename*1*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
284                " filename*2*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
285                " filename*3*=%F0%9F%93%95%F0%9F%93%95-are-placed-on-the-bound;\r\n",
286                " filename*4*=ary.txt"
287            )
288        );
289    }
290
291    #[test]
292    fn parameter_special_long_part2() {
293        let mut s = "Content-Disposition: attachment;".to_owned();
294        let line_len = s.len();
295
296        {
297            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
298            w.space();
299            encode(
300                "filename",
301                "testing-to-see-what-happens-when-books-are-placed-in-the-second-part-πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•.txt",
302                &mut w,
303            )
304            .unwrap();
305        }
306
307        assert_eq!(
308            s,
309            concat!(
310                "Content-Disposition: attachment;\r\n",
311                " filename*0*=utf-8''testing-to-see-what-happens-when-books-ar;\r\n",
312                " filename*1*=e-placed-in-the-second-part-%F0%9F%93%95%F0%9F%93%95;\r\n",
313                " filename*2*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
314                " filename*3*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
315                " filename*4*=%F0%9F%93%95.txt"
316            )
317        );
318    }
319
320    #[test]
321    fn parameter_dont_split_on_hex_boundary() {
322        let base_header = "Content-Disposition: attachment;".to_owned();
323        let line_len = base_header.len();
324
325        for start_offset in &["", "x", "xx", "xxx"] {
326            let mut filename = (*start_offset).to_owned();
327
328            for i in 1..256 {
329                // 'Ü' results in two hex chars %C3%9C
330                filename.push('Ü');
331
332                let mut output = base_header.clone();
333                {
334                    let mut w = EmailWriter::new(&mut output, line_len, 0, true);
335                    encode("filename", &filename, &mut w).unwrap();
336                }
337
338                // look for all hex encoded chars
339                let output_len = output.len();
340                let mut found_hex_count = 0;
341                for (percent_sign_idx, _) in output.match_indices('%') {
342                    assert!(percent_sign_idx + 3 <= output_len);
343
344                    // verify we get the expected hex sequence for an 'Ü'
345                    let must_be_hex = &output[percent_sign_idx + 1..percent_sign_idx + 3];
346                    assert!(
347                        must_be_hex == "C3" || must_be_hex == "9C",
348                        "unexpected hex char: {}",
349                        must_be_hex
350                    );
351                    found_hex_count += 1;
352                }
353                // verify the number of hex encoded chars adds up
354                let number_of_chars_in_hex = 2;
355                assert_eq!(found_hex_count, i * number_of_chars_in_hex);
356
357                // verify max line length
358                let mut last_newline_pos = 0;
359                for (newline_idx, _) in output.match_indices("\r\n") {
360                    let line_length = newline_idx - last_newline_pos;
361                    assert!(
362                        line_length < MAX_LINE_LEN,
363                        "expected line length exceeded: {} > {}",
364                        line_length,
365                        MAX_LINE_LEN
366                    );
367                    last_newline_pos = newline_idx;
368                }
369                // ensure there was at least one newline
370                assert_ne!(0, last_newline_pos);
371            }
372        }
373    }
374
375    #[test]
376    #[should_panic(expected = "`key` must only be composed of ascii alphanumeric chars")]
377    fn non_ascii_key() {
378        let mut s = String::new();
379        let mut w = EmailWriter::new(&mut s, 0, 0, true);
380        let _ = encode("πŸ“¬", "", &mut w);
381    }
382}