Skip to main content

rustlavel_mail/
encode.rs

1//! The encodings a message has to speak.
2//!
3//! Base64, quoted-printable, RFC 2047 encoded-words, header folding, RFC 5322
4//! dates, and SMTP dot-stuffing. None of it is interesting, and all of it is
5//! where mail actually breaks: a header that is one byte too long, a body line
6//! that starts with a dot, an accented name that arrives as mojibake.
7
8use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
12
13/// Where a header line is folded. RFC 5322 recommends 78 characters; the hard
14/// limit is 998, and some servers are unhappy well before it.
15const HEADER_WIDTH: usize = 76;
16
17/// The longest line quoted-printable may emit, counting the soft break.
18const QP_WIDTH: usize = 76;
19
20/// 57 input bytes encode to exactly 76 base64 characters.
21const BASE64_LINE_INPUT: usize = 57;
22
23pub fn base64_encode(input: &[u8]) -> String {
24    let mut out = String::with_capacity(input.len().div_ceil(3) * 4);
25
26    for chunk in input.chunks(3) {
27        let b = [chunk[0], *chunk.get(1).unwrap_or(&0), *chunk.get(2).unwrap_or(&0)];
28        let bits = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
29
30        out.push(ALPHABET[(bits >> 18 & 0x3f) as usize] as char);
31        out.push(ALPHABET[(bits >> 12 & 0x3f) as usize] as char);
32        out.push(if chunk.len() > 1 { ALPHABET[(bits >> 6 & 0x3f) as usize] as char } else { '=' });
33        out.push(if chunk.len() > 2 { ALPHABET[(bits & 0x3f) as usize] as char } else { '=' });
34    }
35
36    out
37}
38
39pub fn base64_decode(input: &str) -> Option<Vec<u8>> {
40    let mut out = Vec::with_capacity(input.len() / 4 * 3);
41    let mut buffer = 0u32;
42    let mut bits = 0u32;
43
44    for byte in input.bytes() {
45        let value = match byte {
46            b'A'..=b'Z' => byte - b'A',
47            b'a'..=b'z' => byte - b'a' + 26,
48            b'0'..=b'9' => byte - b'0' + 52,
49            b'+' => 62,
50            b'/' => 63,
51            b'=' | b'\n' | b'\r' => continue,
52            _ => return None,
53        };
54
55        buffer = (buffer << 6) | u32::from(value);
56        bits += 6;
57        if bits >= 8 {
58            bits -= 8;
59            out.push((buffer >> bits) as u8);
60        }
61    }
62
63    Some(out)
64}
65
66/// Base64 wrapped at 76 characters with CRLF, which is what a MIME body part
67/// must look like — an unwrapped 2 MB single line is not a legal message.
68pub fn base64_body(input: &[u8]) -> String {
69    let mut out = String::new();
70    for (index, chunk) in input.chunks(BASE64_LINE_INPUT).enumerate() {
71        if index > 0 {
72            out.push_str("\r\n");
73        }
74        out.push_str(&base64_encode(chunk));
75    }
76    out
77}
78
79/// Every line ending becomes CRLF, which is the only line ending a message on
80/// the wire is allowed to have.
81pub fn normalize_crlf(input: &str) -> String {
82    input.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "\r\n")
83}
84
85/// Encode a body part as quoted-printable (RFC 2045).
86///
87/// Preferred over base64 for text because the result stays readable: a mostly
88/// ASCII message is still legible in a raw `.eml`, which matters the day
89/// somebody has to debug one.
90pub fn quoted_printable(input: &str) -> String {
91    let normalized = normalize_crlf(input);
92    let bytes = normalized.as_bytes();
93    let mut out = String::with_capacity(bytes.len() + bytes.len() / 4);
94    let mut column = 0usize;
95    let mut index = 0usize;
96
97    while index < bytes.len() {
98        let byte = bytes[index];
99
100        if byte == b'\r' && bytes.get(index + 1) == Some(&b'\n') {
101            out.push_str("\r\n");
102            column = 0;
103            index += 2;
104            continue;
105        }
106
107        // Whitespace at the end of a line has to be encoded: a relay is
108        // allowed to strip trailing spaces, and would change the message.
109        let ends_the_line = matches!(bytes.get(index + 1), None | Some(b'\r'));
110        let piece = match byte {
111            b' ' | b'\t' if !ends_the_line => (byte as char).to_string(),
112            33..=60 | 62..=126 => (byte as char).to_string(),
113            other => format!("={other:02X}"),
114        };
115
116        // A soft break the decoder removes. Checked against the whole piece so
117        // an `=XX` triple is never split in half.
118        if column + piece.len() > QP_WIDTH - 1 {
119            out.push_str("=\r\n");
120            column = 0;
121        }
122        out.push_str(&piece);
123        column += piece.len();
124        index += 1;
125    }
126
127    out
128}
129
130/// Whether a header value can be written literally.
131fn is_plain_header_text(text: &str) -> bool {
132    text.is_ascii() && !text.chars().any(|c| c.is_ascii_control())
133}
134
135/// Encode a header value as RFC 2047 encoded-words when it is not plain ASCII.
136///
137/// Headers are ASCII by definition, so `Grüße` has to travel as
138/// `=?UTF-8?B?R3LDvMOfZQ==?=`. Long values become several encoded-words
139/// separated by a fold, because one word may not exceed 75 characters.
140pub fn encode_word(text: &str) -> String {
141    if is_plain_header_text(text) {
142        return text.to_string();
143    }
144
145    // `=?UTF-8?B?` + `?=` is 12 characters; 45 input bytes encode to 60, which
146    // keeps every word inside the 75-character limit with room to spare.
147    const CHUNK: usize = 45;
148
149    let mut words: Vec<String> = Vec::new();
150    let mut chunk = String::new();
151
152    for ch in text.chars() {
153        // Control characters would survive base64 and reappear in a decoded
154        // header, so they are dropped rather than encoded.
155        if ch.is_control() {
156            continue;
157        }
158        if chunk.len() + ch.len_utf8() > CHUNK {
159            words.push(format!("=?UTF-8?B?{}?=", base64_encode(chunk.as_bytes())));
160            chunk.clear();
161        }
162        chunk.push(ch);
163    }
164    if !chunk.is_empty() {
165        words.push(format!("=?UTF-8?B?{}?=", base64_encode(chunk.as_bytes())));
166    }
167
168    words.join("\r\n ")
169}
170
171/// Strip anything that would let a value break out of its header.
172///
173/// A subject arriving from a form is attacker-controlled; a bare CRLF in it
174/// would start a new header, which is how `Bcc:` gets added to somebody else's
175/// mail. Folding whitespace is collapsed to a single space.
176pub fn sanitize_header_text(text: &str) -> String {
177    let mut out = String::with_capacity(text.len());
178    let mut last_was_space = false;
179
180    for ch in text.chars() {
181        let space = ch == '\r' || ch == '\n' || ch == '\t' || ch == ' ';
182        if space {
183            if !last_was_space && !out.is_empty() {
184                out.push(' ');
185            }
186            last_was_space = true;
187            continue;
188        }
189        if ch.is_control() {
190            continue;
191        }
192        out.push(ch);
193        last_was_space = false;
194    }
195
196    out.trim_end().to_string()
197}
198
199/// Write `Name: value`, folded so no line runs past [`HEADER_WIDTH`].
200///
201/// Folds happen at spaces the value already contains — a header may be split
202/// only at folding whitespace, never mid-token. A value that is one long token
203/// stays long, which is legal and the only correct answer.
204pub fn header_line(name: &str, value: &str) -> String {
205    let mut out = String::with_capacity(name.len() + value.len() + 4);
206    out.push_str(name);
207    out.push_str(": ");
208    let mut column = name.len() + 2;
209
210    // The value may already contain folds, from `encode_word`.
211    for (segment_index, segment) in value.split("\r\n ").enumerate() {
212        if segment_index > 0 {
213            out.push_str("\r\n ");
214            column = 1;
215        }
216        for (token_index, token) in segment.split(' ').enumerate() {
217            if token_index > 0 {
218                if column + 1 + token.len() > HEADER_WIDTH {
219                    out.push_str("\r\n ");
220                    column = 1;
221                } else {
222                    out.push(' ');
223                    column += 1;
224                }
225            }
226            out.push_str(token);
227            column += token.len();
228        }
229    }
230
231    out.push_str("\r\n");
232    out
233}
234
235/// Prefix any line that starts with `.` with a second one.
236///
237/// A lone `.` on its own line ends the SMTP DATA phase, so a message body
238/// containing one would be truncated there — and the rest of it interpreted as
239/// SMTP commands.
240pub fn dot_stuff(data: &str) -> String {
241    let normalized = normalize_crlf(data);
242    let mut out = String::with_capacity(normalized.len() + 8);
243
244    for (index, line) in normalized.split("\r\n").enumerate() {
245        if index > 0 {
246            out.push_str("\r\n");
247        }
248        if line.starts_with('.') {
249            out.push('.');
250        }
251        out.push_str(line);
252    }
253
254    out
255}
256
257/// Percent-encode a filename for an RFC 2231 `filename*` parameter.
258pub fn percent_encode(text: &str) -> String {
259    let mut out = String::with_capacity(text.len());
260    for byte in text.bytes() {
261        match byte {
262            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
263                out.push(byte as char)
264            }
265            other => out.push_str(&format!("%{other:02X}")),
266        }
267    }
268    out
269}
270
271const WEEKDAYS: [&str; 7] = ["Thu", "Fri", "Sat", "Sun", "Mon", "Tue", "Wed"];
272const MONTHS: [&str; 12] =
273    ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
274
275/// Format a `Date:` header, RFC 5322 style, in UTC.
276///
277/// Always `+0000`: the framework has no timezone database, and a wrong offset
278/// is worse than an honest one — mail clients sort by this field.
279pub fn rfc5322_date(time: SystemTime) -> String {
280    let seconds = time.duration_since(UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
281    let days = seconds.div_euclid(86_400);
282    let rest = seconds.rem_euclid(86_400);
283    let (hour, minute, second) = (rest / 3600, (rest % 3600) / 60, rest % 60);
284
285    // 1970-01-01 was a Thursday, which is why WEEKDAYS starts there.
286    let weekday = WEEKDAYS[days.rem_euclid(7) as usize];
287    let (year, month, day) = civil_from_days(days);
288
289    format!(
290        "{weekday}, {day:02} {} {year} {hour:02}:{minute:02}:{second:02} +0000",
291        MONTHS[(month - 1) as usize]
292    )
293}
294
295/// Days since the epoch to a calendar date, by Howard Hinnant's algorithm.
296fn civil_from_days(days: i64) -> (i64, i64, i64) {
297    let shifted = days + 719_468;
298    let era = if shifted >= 0 { shifted } else { shifted - 146_096 } / 146_097;
299    let day_of_era = shifted - era * 146_097;
300    let year_of_era =
301        (day_of_era - day_of_era / 1460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
302    let year = year_of_era + era * 400;
303    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
304    let month_prime = (5 * day_of_year + 2) / 153;
305    let day = day_of_year - (153 * month_prime + 2) / 5 + 1;
306    let month = if month_prime < 10 { month_prime + 3 } else { month_prime - 9 };
307
308    (if month <= 2 { year + 1 } else { year }, month, day)
309}
310
311/// A value unique within this process, for boundaries and message ids.
312///
313/// A counter rather than randomness: a boundary only has to be absent from the
314/// body it delimits, and a message id only has to be unique per sender.
315pub fn unique_token() -> String {
316    static COUNTER: AtomicU64 = AtomicU64::new(0);
317    let nanos = SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0);
318    let count = COUNTER.fetch_add(1, Ordering::Relaxed);
319    format!("{nanos:x}{count:x}{:x}", std::process::id())
320}
321
322#[cfg(test)]
323mod tests {
324    use super::*;
325
326    #[test]
327    fn base64_matches_the_rfc_4648_vectors() {
328        for (plain, encoded) in
329            [("", ""), ("f", "Zg=="), ("fo", "Zm8="), ("foo", "Zm9v"), ("foobar", "Zm9vYmFy")]
330        {
331            assert_eq!(base64_encode(plain.as_bytes()), encoded);
332            assert_eq!(base64_decode(encoded).unwrap(), plain.as_bytes());
333        }
334    }
335
336    #[test]
337    fn a_base64_body_is_wrapped_at_seventy_six_characters() {
338        let body = base64_body(&[b'x'; 200]);
339
340        for line in body.split("\r\n") {
341            assert!(line.len() <= 76, "line of {} characters: {line}", line.len());
342        }
343        assert_eq!(base64_decode(&body).unwrap(), [b'x'; 200]);
344    }
345
346    #[test]
347    fn quoted_printable_escapes_what_it_must_and_leaves_the_rest_readable() {
348        assert_eq!(quoted_printable("hello world"), "hello world");
349        assert_eq!(quoted_printable("caf\u{e9}"), "caf=C3=A9");
350        assert_eq!(quoted_printable("1 = 1"), "1 =3D 1");
351        // A trailing space would be stripped by a relay, so it is encoded.
352        assert_eq!(quoted_printable("trailing "), "trailing=20");
353        assert_eq!(quoted_printable("two\nlines"), "two\r\nlines");
354    }
355
356    /// Undo quoted-printable, so a round trip can be asserted rather than a
357    /// shape guessed at.
358    fn undo_quoted_printable(encoded: &str) -> Vec<u8> {
359        let joined = encoded.replace("=\r\n", "");
360        let bytes = joined.as_bytes();
361        let mut out = Vec::with_capacity(bytes.len());
362        let mut index = 0;
363
364        while index < bytes.len() {
365            if bytes[index] == b'=' && index + 2 < bytes.len() {
366                let hex = std::str::from_utf8(&bytes[index + 1..index + 3]).unwrap();
367                out.push(u8::from_str_radix(hex, 16).expect("two hex digits"));
368                index += 3;
369            } else {
370                out.push(bytes[index]);
371                index += 1;
372            }
373        }
374
375        out
376    }
377
378    #[test]
379    fn quoted_printable_soft_wraps_without_splitting_an_escape() {
380        let source = "\u{e9}".repeat(60);
381        let encoded = quoted_printable(&source);
382
383        assert!(encoded.contains("=\r\n"), "a long line should have been folded");
384        for line in encoded.split("\r\n") {
385            assert!(line.len() <= 76, "line of {} characters: {line}", line.len());
386        }
387        // The only proof that matters: it decodes back to what went in.
388        assert_eq!(undo_quoted_printable(&encoded), source.as_bytes());
389    }
390
391    #[test]
392    fn plain_ascii_headers_are_left_alone() {
393        assert_eq!(encode_word("Your receipt"), "Your receipt");
394    }
395
396    #[test]
397    fn a_non_ascii_header_becomes_an_encoded_word() {
398        assert_eq!(encode_word("Gr\u{fc}\u{df}e"), "=?UTF-8?B?R3LDvMOfZQ==?=");
399    }
400
401    #[test]
402    fn a_long_non_ascii_header_becomes_several_words_split_on_characters() {
403        let encoded = encode_word(&"\u{e9}".repeat(60));
404        let words: Vec<&str> = encoded.split("\r\n ").collect();
405
406        assert!(words.len() > 1, "expected a fold: {encoded}");
407        let mut decoded = Vec::new();
408        for word in words {
409            assert!(word.len() <= 75, "encoded word too long: {word}");
410            let payload = word.trim_start_matches("=?UTF-8?B?").trim_end_matches("?=");
411            decoded.extend(base64_decode(payload).unwrap());
412        }
413        assert_eq!(String::from_utf8(decoded).unwrap(), "\u{e9}".repeat(60));
414    }
415
416    #[test]
417    fn header_text_cannot_smuggle_a_second_header() {
418        let injected = sanitize_header_text("Receipt\r\nBcc: evil@example.com");
419
420        assert_eq!(injected, "Receipt Bcc: evil@example.com");
421        assert!(!injected.contains('\r') && !injected.contains('\n'));
422    }
423
424    #[test]
425    fn a_long_header_folds_at_spaces_only() {
426        let line = header_line("Subject", &"word ".repeat(30));
427
428        for (index, folded) in line.trim_end().split("\r\n").enumerate() {
429            assert!(folded.len() <= HEADER_WIDTH, "line too long: {folded}");
430            if index > 0 {
431                assert!(folded.starts_with(' '), "a continuation must start with whitespace");
432            }
433        }
434        assert!(line.ends_with("\r\n"));
435    }
436
437    #[test]
438    fn a_short_header_is_one_line() {
439        assert_eq!(header_line("To", "ada@example.com"), "To: ada@example.com\r\n");
440    }
441
442    #[test]
443    fn lines_beginning_with_a_dot_are_stuffed() {
444        assert_eq!(dot_stuff(".\r\nbody"), "..\r\nbody");
445        assert_eq!(dot_stuff("...ellipsis"), "....ellipsis");
446        assert_eq!(dot_stuff("no dots here"), "no dots here");
447        // Only the start of a line matters.
448        assert_eq!(dot_stuff("a.b"), "a.b");
449    }
450
451    #[test]
452    fn dates_render_in_the_shape_rfc_5322_asks_for() {
453        assert_eq!(
454            rfc5322_date(UNIX_EPOCH),
455            "Thu, 01 Jan 1970 00:00:00 +0000"
456        );
457        assert_eq!(
458            rfc5322_date(UNIX_EPOCH + std::time::Duration::from_secs(1_234_567_890)),
459            "Fri, 13 Feb 2009 23:31:30 +0000"
460        );
461        assert_eq!(
462            rfc5322_date(UNIX_EPOCH + std::time::Duration::from_secs(1_709_164_800)),
463            "Thu, 29 Feb 2024 00:00:00 +0000"
464        );
465    }
466
467    #[test]
468    fn unique_tokens_do_not_repeat() {
469        let first = unique_token();
470        let second = unique_token();
471        assert_ne!(first, second);
472    }
473
474    #[test]
475    fn filenames_percent_encode_for_rfc_2231() {
476        assert_eq!(percent_encode("r\u{e9}sum\u{e9}.pdf"), "r%C3%A9sum%C3%A9.pdf");
477        assert_eq!(percent_encode("plain.txt"), "plain.txt");
478    }
479}