1use std::sync::atomic::{AtomicU64, Ordering};
9use std::time::{SystemTime, UNIX_EPOCH};
10
11const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
12
13const HEADER_WIDTH: usize = 76;
16
17const QP_WIDTH: usize = 76;
19
20const 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
66pub 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
79pub fn normalize_crlf(input: &str) -> String {
82 input.replace("\r\n", "\n").replace('\r', "\n").replace('\n', "\r\n")
83}
84
85pub 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 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 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
130fn is_plain_header_text(text: &str) -> bool {
132 text.is_ascii() && !text.chars().any(|c| c.is_ascii_control())
133}
134
135pub fn encode_word(text: &str) -> String {
141 if is_plain_header_text(text) {
142 return text.to_string();
143 }
144
145 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 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
171pub 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
199pub 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 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
235pub 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
257pub 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
275pub 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 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
295fn 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
311pub 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 assert_eq!(quoted_printable("trailing "), "trailing=20");
353 assert_eq!(quoted_printable("two\nlines"), "two\r\nlines");
354 }
355
356 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 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 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}