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::{hex_encoding, utils, writer::EmailWriter, MAX_LINE_LEN};
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, concat!("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!(
197            s,
198            concat!("Content-Disposition: attachment; filename=\"duck.txt\"")
199        );
200    }
201
202    #[test]
203    fn parameter_to_escape() {
204        let mut s = "Content-Disposition: attachment;".to_owned();
205        let line_len = 1;
206
207        {
208            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
209            w.space();
210            encode("filename", "du\"ck\\.txt", &mut w).unwrap();
211        }
212
213        assert_eq!(
214            s,
215            concat!("Content-Disposition: attachment; filename=\"du\\\"ck\\\\.txt\"")
216        );
217    }
218
219    #[test]
220    fn parameter_long() {
221        let mut s = "Content-Disposition: attachment;".to_owned();
222        let line_len = s.len();
223
224        {
225            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
226            w.space();
227            encode(
228                "filename",
229                "a-fairly-long-filename-just-to-see-what-happens-when-we-encode-it-will-the-client-be-able-to-handle-it.txt",
230                &mut w,
231            )
232            .unwrap();
233        }
234
235        assert_eq!(
236            s,
237            concat!(
238                "Content-Disposition: attachment;\r\n",
239                " filename*0=\"a-fairly-long-filename-just-to-see-what-happens-when-we-enco\";\r\n",
240                " filename*1=\"de-it-will-the-client-be-able-to-handle-it.txt\""
241            )
242        );
243    }
244
245    #[test]
246    fn parameter_special() {
247        let mut s = "Content-Disposition: attachment;".to_owned();
248        let line_len = s.len();
249
250        {
251            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
252            w.space();
253            encode("filename", "caffè.txt", &mut w).unwrap();
254        }
255
256        assert_eq!(
257            s,
258            concat!(
259                "Content-Disposition: attachment;\r\n",
260                " filename*0*=utf-8''caff%C3%A8.txt"
261            )
262        );
263    }
264
265    #[test]
266    fn parameter_special_long() {
267        let mut s = "Content-Disposition: attachment;".to_owned();
268        let line_len = s.len();
269
270        {
271            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
272            w.space();
273            encode(
274                "filename",
275                "testing-to-see-what-happens-when-πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•-are-placed-on-the-boundary.txt",
276                &mut w,
277            )
278            .unwrap();
279        }
280
281        assert_eq!(
282            s,
283            concat!(
284                "Content-Disposition: attachment;\r\n",
285                " filename*0*=utf-8''testing-to-see-what-happens-when-%F0%9F%93%95;\r\n",
286                " filename*1*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
287                " filename*2*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
288                " filename*3*=%F0%9F%93%95%F0%9F%93%95-are-placed-on-the-bound;\r\n",
289                " filename*4*=ary.txt"
290            )
291        );
292    }
293
294    #[test]
295    fn parameter_special_long_part2() {
296        let mut s = "Content-Disposition: attachment;".to_owned();
297        let line_len = s.len();
298
299        {
300            let mut w = EmailWriter::new(&mut s, line_len, 0, true);
301            w.space();
302            encode(
303                "filename",
304                "testing-to-see-what-happens-when-books-are-placed-in-the-second-part-πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•πŸ“•.txt",
305                &mut w,
306            )
307            .unwrap();
308        }
309
310        assert_eq!(
311            s,
312            concat!(
313                "Content-Disposition: attachment;\r\n",
314                " filename*0*=utf-8''testing-to-see-what-happens-when-books-ar;\r\n",
315                " filename*1*=e-placed-in-the-second-part-%F0%9F%93%95%F0%9F%93%95;\r\n",
316                " filename*2*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
317                " filename*3*=%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95%F0%9F%93%95;\r\n",
318                " filename*4*=%F0%9F%93%95.txt"
319            )
320        );
321    }
322
323    #[test]
324    fn parameter_dont_split_on_hex_boundary() {
325        let base_header = "Content-Disposition: attachment;".to_owned();
326        let line_len = base_header.len();
327
328        for start_offset in &["", "x", "xx", "xxx"] {
329            let mut filename = (*start_offset).to_owned();
330
331            for i in 1..256 {
332                // 'Ü' results in two hex chars %C3%9C
333                filename.push('Ü');
334
335                let mut output = base_header.clone();
336                {
337                    let mut w = EmailWriter::new(&mut output, line_len, 0, true);
338                    encode("filename", &filename, &mut w).unwrap();
339                }
340
341                // look for all hex encoded chars
342                let output_len = output.len();
343                let mut found_hex_count = 0;
344                for (percent_sign_idx, _) in output.match_indices('%') {
345                    assert!(percent_sign_idx + 3 <= output_len);
346
347                    // verify we get the expected hex sequence for an 'Ü'
348                    let must_be_hex = &output[percent_sign_idx + 1..percent_sign_idx + 3];
349                    assert!(
350                        must_be_hex == "C3" || must_be_hex == "9C",
351                        "unexpected hex char: {}",
352                        must_be_hex
353                    );
354                    found_hex_count += 1;
355                }
356                // verify the number of hex encoded chars adds up
357                let number_of_chars_in_hex = 2;
358                assert_eq!(found_hex_count, i * number_of_chars_in_hex);
359
360                // verify max line length
361                let mut last_newline_pos = 0;
362                for (newline_idx, _) in output.match_indices("\r\n") {
363                    let line_length = newline_idx - last_newline_pos;
364                    assert!(
365                        line_length < MAX_LINE_LEN,
366                        "expected line length exceeded: {} > {}",
367                        line_length,
368                        MAX_LINE_LEN
369                    );
370                    last_newline_pos = newline_idx;
371                }
372                // ensure there was at least one newline
373                assert_ne!(0, last_newline_pos);
374            }
375        }
376    }
377
378    #[test]
379    #[should_panic(expected = "`key` must only be composed of ascii alphanumeric chars")]
380    fn non_ascii_key() {
381        let mut s = String::new();
382        let mut w = EmailWriter::new(&mut s, 0, 0, true);
383        let _ = encode("πŸ“¬", "", &mut w);
384    }
385}