1use std::borrow::Cow;
2
3use crate::{
4 encoders::{base64::MIME, encode::BodyEncoding},
5 headers::{
6 Header, HeaderType,
7 content_type::ContentType,
8 fold::{FoldWriter, write_control_free},
9 message_id::MessageId,
10 raw::Raw,
11 text::Text,
12 },
13 writer::Writer,
14};
15
16#[derive(Clone, Debug)]
18pub struct MimePart<'x> {
19 pub headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
20 pub contents: BodyPart<'x>,
21}
22
23#[derive(Clone, Debug)]
24pub enum BodyPart<'x> {
25 Text(Cow<'x, str>),
26 Binary(Cow<'x, [u8]>),
27 Multipart(Vec<MimePart<'x>>),
28}
29
30impl<'x> From<&'x str> for BodyPart<'x> {
31 fn from(value: &'x str) -> Self {
32 BodyPart::Text(value.into())
33 }
34}
35
36impl<'x> From<&'x [u8]> for BodyPart<'x> {
37 fn from(value: &'x [u8]) -> Self {
38 BodyPart::Binary(value.into())
39 }
40}
41
42impl From<String> for BodyPart<'_> {
43 fn from(value: String) -> Self {
44 BodyPart::Text(value.into())
45 }
46}
47
48impl<'x> From<&'x String> for BodyPart<'x> {
49 fn from(value: &'x String) -> Self {
50 BodyPart::Text(value.as_str().into())
51 }
52}
53
54impl<'x> From<Cow<'x, str>> for BodyPart<'x> {
55 fn from(value: Cow<'x, str>) -> Self {
56 BodyPart::Text(value)
57 }
58}
59
60impl From<Vec<u8>> for BodyPart<'_> {
61 fn from(value: Vec<u8>) -> Self {
62 BodyPart::Binary(value.into())
63 }
64}
65
66impl<'x> From<Vec<MimePart<'x>>> for BodyPart<'x> {
67 fn from(value: Vec<MimePart<'x>>) -> Self {
68 BodyPart::Multipart(value)
69 }
70}
71
72impl<'x> From<&'x str> for ContentType<'x> {
73 fn from(value: &'x str) -> Self {
74 ContentType::new(value)
75 }
76}
77
78impl From<String> for ContentType<'_> {
79 fn from(value: String) -> Self {
80 ContentType::new(value)
81 }
82}
83
84impl<'x> From<&'x String> for ContentType<'x> {
85 fn from(value: &'x String) -> Self {
86 ContentType::new(value.as_str())
87 }
88}
89
90const HEX_DIGITS: &[u8; 16] = b"0123456789abcdef";
91
92#[inline]
94fn write_hex(output: &mut impl Writer, mut value: u64) {
95 let mut buffer = [0u8; 16];
96 let mut at = buffer.len();
97 loop {
98 at -= 1;
99 if let Some(slot) = buffer.get_mut(at) {
100 *slot = HEX_DIGITS[(value & 0x0F) as usize];
101 }
102 value >>= 4;
103 if value == 0 || at == 0 {
104 break;
105 }
106 }
107 output.write(buffer.get(at..).unwrap_or_default());
108}
109
110pub fn write_boundary(output: &mut impl Writer, separator: &str) {
116 write_hex(output, rand::random::<u64>());
117 output.write(separator.as_bytes());
118 write_hex(output, rand::random::<u64>());
119}
120
121pub fn make_boundary(separator: &str) -> String {
123 let mut boundary = Vec::with_capacity(2 * 16 + separator.len());
124 write_boundary(&mut boundary, separator);
125 String::from_utf8(boundary).unwrap_or_default()
127}
128
129fn fresh_boundary(parts: &[MimePart<'_>]) -> String {
138 loop {
139 let boundary = make_boundary("_");
140 if !parts
141 .iter()
142 .any(|part| part_contains_boundary(part, boundary.as_bytes()))
143 {
144 return boundary;
145 }
146 }
147}
148
149fn part_contains_boundary(part: &MimePart<'_>, boundary: &[u8]) -> bool {
150 match &part.contents {
151 BodyPart::Text(text) => contains_boundary_line(text.as_bytes(), boundary),
152 BodyPart::Binary(binary) => contains_boundary_line(binary, boundary),
153 BodyPart::Multipart(children) => children.iter().any(|child| part_contains_boundary(child, boundary)),
154 }
155}
156
157fn contains_boundary_line(data: &[u8], boundary: &[u8]) -> bool {
162 let mut start = 0;
163 while start <= data.len() {
164 let rest = data.get(start..).unwrap_or_default();
165 if rest.starts_with(b"--") && rest.get(2..).is_some_and(|tail| tail.starts_with(boundary)) {
166 return true;
167 }
168 match rest.iter().position(|&byte| byte == b'\n') {
169 Some(position) => start += position + 1,
170 None => break,
171 }
172 }
173 false
174}
175
176impl<'x> MimePart<'x> {
177 pub fn new(content_type: impl Into<ContentType<'x>>, contents: impl Into<BodyPart<'x>>) -> Self {
179 let mut content_type = content_type.into();
180 let contents = contents.into();
181
182 if matches!(contents, BodyPart::Text(_)) && content_type.attributes.is_empty() {
183 content_type.attributes.reserve_exact(1);
184 content_type.attributes.push((Cow::from("charset"), Cow::from("utf-8")));
185 }
186
187 let mut headers = Vec::with_capacity(2);
188 headers.push((Cow::from("Content-Type"), content_type.into()));
189
190 Self {
191 contents,
192 headers,
193 }
194 }
195
196 pub fn raw(contents: impl Into<BodyPart<'x>>) -> Self {
198 Self {
199 contents: contents.into(),
200 headers: vec![],
201 }
202 }
203
204 pub fn attachment(mut self, filename: impl Into<Cow<'x, str>>) -> Self {
206 self.headers.push((
207 "Content-Disposition".into(),
208 ContentType::new("attachment").attribute("filename", filename).into(),
209 ));
210 self
211 }
212
213 pub fn inline(mut self) -> Self {
215 self.headers
216 .push(("Content-Disposition".into(), ContentType::new("inline").into()));
217 self
218 }
219
220 pub fn language(mut self, value: impl Into<Cow<'x, str>>) -> Self {
222 self.headers.push(("Content-Language".into(), Text::new(value).into()));
223 self
224 }
225
226 pub fn cid(mut self, value: impl Into<Cow<'x, str>>) -> Self {
228 self.headers.push(("Content-ID".into(), MessageId::new(value).into()));
229 self
230 }
231
232 pub fn location(mut self, value: impl Into<Cow<'x, str>>) -> Self {
234 self.headers.push(("Content-Location".into(), Raw::new(value).into()));
235 self
236 }
237
238 pub fn transfer_encoding(mut self, value: impl Into<Cow<'x, str>>) -> Self {
240 self.headers
241 .push(("Content-Transfer-Encoding".into(), Raw::new(value).into()));
242 self
243 }
244
245 pub fn header(mut self, header: impl Into<Cow<'x, str>>, value: impl Into<HeaderType<'x>>) -> Self {
247 self.headers.push((header.into(), value.into()));
248 self
249 }
250
251 pub fn size(&self) -> usize {
253 match &self.contents {
254 BodyPart::Text(b) => b.len(),
255 BodyPart::Binary(b) => b.len(),
256 BodyPart::Multipart(bl) => bl.iter().map(|b| b.size()).sum(),
257 }
258 }
259
260 pub(crate) fn estimated_len(&self) -> usize {
261 self.estimated_len_at_depth(0)
262 }
263
264 fn estimated_len_at_depth(&self, depth: usize) -> usize {
265 let headers = estimated_headers_len(&self.headers);
266 match &self.contents {
267 BodyPart::Text(text) => headers
268 .saturating_add(MIME.encoded_len(text.len()))
269 .saturating_add(LEAF_OVERHEAD),
270 BodyPart::Binary(binary) => headers
271 .saturating_add(MIME.encoded_len(binary.len()))
272 .saturating_add(LEAF_OVERHEAD),
273 BodyPart::Multipart(parts) if depth < MAX_ESTIMATE_DEPTH => {
274 parts
275 .iter()
276 .fold(headers.saturating_add(MULTIPART_OVERHEAD), |total, part| {
277 total
278 .saturating_add(part.estimated_len_at_depth(depth + 1))
279 .saturating_add(BOUNDARY_OVERHEAD)
280 })
281 }
282 BodyPart::Multipart(parts) => headers
283 .saturating_add(MULTIPART_OVERHEAD)
284 .saturating_add(parts.len().saturating_mul(BOUNDARY_OVERHEAD)),
285 }
286 }
287
288 pub fn add_part(&mut self, part: MimePart<'x>) {
290 if let BodyPart::Multipart(ref mut parts) = self.contents {
291 parts.push(part);
292 }
293 }
294
295 pub fn write_part(self, output: &mut impl Writer) {
297 let children = match self.contents {
298 BodyPart::Text(text) => {
299 return write_leaf_part(&self.headers, text.as_bytes(), true, output);
300 }
301 BodyPart::Binary(binary) => {
302 return write_leaf_part(&self.headers, binary.as_ref(), false, output);
303 }
304 BodyPart::Multipart(children) => children,
305 };
306
307 let mut boundary = write_multipart_headers(self.headers, &children, output);
308 let mut parts = children.into_iter();
309 let mut stack: Vec<(std::vec::IntoIter<MimePart<'x>>, Cow<'x, str>)> = Vec::new();
310
311 loop {
312 while let Some(part) = parts.next() {
313 output.write(b"\r\n--");
314 output.write(boundary.as_bytes());
315 output.write(b"\r\n");
316
317 match part.contents {
318 BodyPart::Text(text) => {
319 write_leaf_part(&part.headers, text.as_bytes(), true, output);
320 }
321 BodyPart::Binary(binary) => {
322 write_leaf_part(&part.headers, binary.as_ref(), false, output);
323 }
324 BodyPart::Multipart(children) => {
325 stack.push((parts, boundary));
326 boundary = write_multipart_headers(part.headers, &children, output);
327 parts = children.into_iter();
328 }
329 }
330 }
331
332 output.write(b"\r\n--");
333 output.write(boundary.as_bytes());
334 output.write(b"--\r\n");
335
336 match stack.pop() {
337 Some((previous_parts, previous_boundary)) => {
338 parts = previous_parts;
339 boundary = previous_boundary;
340 }
341 None => return,
342 }
343 }
344 }
345}
346
347const MAX_ESTIMATE_DEPTH: usize = 32;
348const LEAF_OVERHEAD: usize = 96;
349const MULTIPART_OVERHEAD: usize = 128;
350const BOUNDARY_OVERHEAD: usize = 64;
351
352pub(crate) fn estimated_headers_len(headers: &[(Cow<'_, str>, HeaderType<'_>)]) -> usize {
353 headers.iter().map(|(name, _)| name.len() + HEADER_VALUE_ESTIMATE).sum()
354}
355
356const HEADER_VALUE_ESTIMATE: usize = 64;
357
358fn write_leaf_part(
359 headers: &[(Cow<'_, str>, HeaderType<'_>)],
360 body: &[u8],
361 is_text_body: bool,
362 output: &mut impl Writer,
363) {
364 let mut is_text = is_text_body;
365 let mut is_attachment = false;
366 let mut is_raw = headers.is_empty();
367
368 for (header_name, header_value) in headers {
369 write_header_name(header_name, output);
370
371 if !is_text && header_name.eq_ignore_ascii_case("Content-Type") {
372 is_text = header_value.as_content_type().is_some_and(|value| value.is_text());
373 } else if !is_attachment && header_name.eq_ignore_ascii_case("Content-Disposition") {
374 is_attachment = header_value
375 .as_content_type()
376 .is_some_and(|value| value.is_attachment());
377 } else if !is_raw && header_name.eq_ignore_ascii_case("Content-Transfer-Encoding") {
378 is_raw = true;
379 }
380
381 header_value.write_header(output, header_name.len() + 2);
382 }
383
384 if !is_raw {
385 if is_text {
386 BodyEncoding::for_text(body, !is_attachment).write(body, output);
387 } else {
388 BodyEncoding::Base64.write(body, output);
389 }
390 } else {
391 if !headers.is_empty() {
392 output.write(b"\r\n");
393 }
394 output.write(body);
395 }
396}
397
398fn write_multipart_headers<'x>(
399 headers: Vec<(Cow<'x, str>, HeaderType<'x>)>,
400 children: &[MimePart<'x>],
401 output: &mut impl Writer,
402) -> Cow<'x, str> {
403 let mut boundary: Option<Cow<'x, str>> = None;
404
405 for (header_name, header_value) in headers {
406 write_header_name(&header_name, output);
407
408 if boundary.is_none() && header_name.eq_ignore_ascii_case("Content-Type") {
409 boundary = Some(match header_value {
410 HeaderType::ContentType(mut content_type) => {
411 let position = match content_type
412 .attributes
413 .iter()
414 .position(|(attribute, _)| attribute.eq_ignore_ascii_case("boundary"))
415 {
416 Some(position) => position,
417 None => {
418 let position = content_type.attributes.len();
419 content_type
420 .attributes
421 .push(("boundary".into(), fresh_boundary(children).into()));
422 position
423 }
424 };
425 content_type.write_header(output, 14);
426 content_type.attributes.swap_remove(position).1
427 }
428 HeaderType::Raw(raw) => raw_boundary(raw, children, output),
429 HeaderType::Text(text) => raw_boundary(Raw::new(text.text), children, output),
430 other => {
431 other.write_header(output, header_name.len() + 2);
432 continue;
433 }
434 });
435 } else {
436 header_value.write_header(output, header_name.len() + 2);
437 }
438 }
439
440 let boundary = boundary.unwrap_or_else(|| {
441 output.write(b"Content-Type: ");
442 let boundary = fresh_boundary(children);
443 ContentType::new("multipart/mixed")
444 .attribute("boundary", &boundary)
445 .write_header(output, 14);
446 boundary.into()
447 });
448
449 output.write(b"\r\n");
450 boundary
451}
452
453fn raw_boundary<'x>(raw: Raw<'x>, children: &[MimePart<'x>], output: &mut impl Writer) -> Cow<'x, str> {
454 match raw.raw.find("boundary=\"") {
455 Some(position) => {
456 raw.write_header(output, 14);
457 match raw.raw {
458 Cow::Borrowed(value) => value
459 .get(position..)
460 .and_then(|tail| tail.split('"').nth(1))
461 .map_or_else(|| fresh_boundary(children).into(), Cow::Borrowed),
462 Cow::Owned(value) => value
463 .get(position..)
464 .and_then(|tail| tail.split('"').nth(1))
465 .map_or_else(|| fresh_boundary(children), str::to_string)
466 .into(),
467 }
468 }
469 None => {
470 let boundary = fresh_boundary(children);
471 let mut folder = FoldWriter::new(output, 14);
472 write_control_free(&mut folder, raw.raw.as_bytes());
473 folder.write(b"; boundary=\"");
474 folder.write(boundary.as_bytes());
475 folder.write(b"\"");
476 folder.finish();
477 boundary.into()
478 }
479 }
480}
481
482pub(crate) fn write_header_name(name: &str, output: &mut impl Writer) {
486 let mut start = 0;
487 for (at, byte) in name.bytes().enumerate() {
488 if byte <= 0x20 || byte == 0x7F {
489 if at > start {
490 output.write(name.get(start..at).unwrap_or_default().as_bytes());
491 }
492 start = at + 1;
493 }
494 }
495 if start < name.len() {
496 output.write(name.get(start..).unwrap_or_default().as_bytes());
497 }
498 output.write(b": ");
499}
500
501#[cfg(test)]
502mod tests {
503 use super::*;
504
505 #[test]
506 fn unexpected_multipart_content_type_values_do_not_panic() {
507 let mut output = Vec::new();
508 MimePart::raw(vec![MimePart::new("text/plain", "hello")])
509 .header("Content-Type", Text::new("multipart/mixed"))
510 .write_part(&mut output);
511 let output = String::from_utf8(output).unwrap();
512 assert!(output.starts_with("Content-Type: multipart/mixed; boundary=\""), "{output:?}");
513 assert!(output.ends_with("--\r\n"), "{output:?}");
514
515 let mut output = Vec::new();
516 MimePart::raw(vec![MimePart::new("text/plain", "hello")])
517 .header("Content-Type", MessageId::new("id@example.org"))
518 .write_part(&mut output);
519 let output = String::from_utf8(output).unwrap();
520 assert!(
521 output.contains("Content-Type: multipart/mixed;") && output.contains("boundary=\""),
522 "{output:?}"
523 );
524 }
525
526 #[test]
527 fn raw_content_type_with_boundary_is_written_and_reused() {
528 let mut output = Vec::new();
529 MimePart::raw(vec![MimePart::new("text/plain", "hello")])
530 .header("Content-Type", Raw::new("multipart/mixed; boundary=\"abc\""))
531 .write_part(&mut output);
532 let output = String::from_utf8(output).unwrap();
533 assert!(
534 output.starts_with("Content-Type: multipart/mixed; boundary=\"abc\"\r\n\r\n\r\n--abc\r\n"),
535 "{output:?}"
536 );
537 assert!(output.ends_with("\r\n--abc--\r\n"), "{output:?}");
538 }
539
540 fn is_boundary_safe(value: &str) -> bool {
541 !value.is_empty()
542 && value.bytes().all(|byte| {
543 byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'.' | b'-' | b'+' | b'=' | b':' | b'/')
544 })
545 }
546
547 #[test]
548 fn boundary_has_a_safe_shape() {
549 let boundary = make_boundary("_");
550 assert_eq!(boundary.split('_').count(), 2, "{boundary}");
551 assert!(
552 boundary
553 .split('_')
554 .all(|field| !field.is_empty() && field.bytes().all(|b| b.is_ascii_hexdigit())),
555 "{boundary}"
556 );
557 assert!(boundary.len() <= 70, "{boundary}");
558 assert!(is_boundary_safe(&boundary), "{boundary}");
559 assert_eq!(make_boundary(".").split('.').count(), 2);
560 }
561
562 #[cfg(not(target_arch = "wasm32"))]
563 #[test]
564 fn boundaries_are_unique_across_threads() {
565 let per_thread = 5_000;
566 let threads = (0..8)
567 .map(|_| std::thread::spawn(move || (0..per_thread).map(|_| make_boundary("_")).collect::<Vec<_>>()))
568 .collect::<Vec<_>>();
569 let mut seen = std::collections::HashSet::with_capacity(per_thread * 8);
570 for thread in threads {
571 for boundary in thread.join().expect("thread panicked") {
572 assert!(seen.insert(boundary), "duplicate boundary");
573 }
574 }
575 assert_eq!(seen.len(), per_thread * 8);
576 }
577
578 #[test]
579 fn detects_boundary_lines_only_at_start_of_line() {
580 assert!(contains_boundary_line(b"--abc\r\n", b"abc"));
581 assert!(contains_boundary_line(b"x\r\n--abc\r\n", b"abc"));
582 assert!(contains_boundary_line(b"x\n--abc--\r\n", b"abc"));
583 assert!(!contains_boundary_line(b"x--abc\r\n", b"abc"));
584 assert!(!contains_boundary_line(b" --abc\r\n", b"abc"));
585 assert!(!contains_boundary_line(b"", b"abc"));
586 }
587
588 #[test]
589 fn header_names_are_sanitized() {
590 let mut output = Vec::new();
591 write_header_name("X-Evil\r\nBcc", &mut output);
592 let output = String::from_utf8(output).unwrap();
593 assert_eq!(output, "X-EvilBcc: ");
594 assert!(!output.contains('\n'));
595 }
596
597 #[test]
598 fn estimates_cover_the_output() {
599 let ascii = "lorem ipsum dolor sit amet ".repeat(2_000);
600 let latin = "réunion d'équipe: résumé ".repeat(2_000);
601 let cjk = "안녕하세요 세계 ".repeat(2_000);
602 let parts = vec![
603 MimePart::new("text/plain", ascii.as_str()),
604 MimePart::new("text/plain", latin.as_str()),
605 MimePart::new("text/plain", cjk.as_str()),
606 MimePart::new("image/png", (0..200_000u32).map(|value| value as u8).collect::<Vec<_>>()),
607 MimePart::new(
608 "multipart/alternative",
609 vec![
610 MimePart::new("text/plain", "short"),
611 MimePart::new("text/html", "<p>short</p>"),
612 ],
613 ),
614 ];
615 for part in parts {
616 let estimate = part.estimated_len();
617 let mut output = Vec::new();
618 part.write_part(&mut output);
619 assert!(estimate >= output.len(), "estimate {estimate} below output {}", output.len());
620 }
621 }
622}