1use std::fmt;
4
5use crate::encode::{
6 ContentTransferEncoding, choose_cte, encode_base64, encode_quoted_printable, fold_header,
7 maybe_encode_word,
8};
9use crate::multipart::{PartBytes, multipart_envelope};
10use crate::strict::LintError;
11
12#[derive(Debug, Clone)]
16pub struct Attachment {
17 pub filename: String,
19 pub content_type: String,
21 pub data: Vec<u8>,
23}
24
25impl Attachment {
26 pub fn new(filename: impl Into<String>, content_type: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
28 Self {
29 filename: filename.into(),
30 content_type: content_type.into(),
31 data: data.into(),
32 }
33 }
34}
35
36#[derive(Debug, Clone, Default)]
42pub struct MessageBuilder {
43 from: Option<Address>,
44 reply_to: Option<Address>,
45 to: Vec<Address>,
46 cc: Vec<Address>,
47 bcc: Vec<Address>,
48 subject: Option<String>,
49 date: Option<String>,
50 message_id: Option<String>,
51 text_body: Option<String>,
52 html_body: Option<String>,
53 attachments: Vec<Attachment>,
54 extra_headers: Vec<(String, String)>,
55 report_type: Option<String>,
61}
62
63#[derive(Debug, Clone)]
66struct Address {
67 display: Option<String>,
68 email: String,
69}
70
71impl Address {
72 fn parse(raw: &str) -> Self {
73 let trimmed = raw.trim();
74 if let Some(open) = trimmed.rfind('<')
76 && trimmed.ends_with('>')
77 {
78 let display = trimmed[..open].trim().trim_matches('"').to_string();
79 let email = trimmed[open + 1..trimmed.len() - 1].trim().to_string();
80 return Self {
81 display: if display.is_empty() { None } else { Some(display) },
82 email,
83 };
84 }
85 Self {
87 display: None,
88 email: trimmed.to_string(),
89 }
90 }
91
92 fn render(&self) -> String {
93 match &self.display {
94 None => self.email.clone(),
95 Some(d) => {
96 let encoded = maybe_encode_word(d);
97 let needs_quotes = !d.is_ascii() || d.contains([',', ';', '<', '>', '@', '"']);
98 if needs_quotes && d.is_ascii() {
99 format!("\"{d}\" <{}>", self.email)
100 } else if encoded == d.as_str() {
101 format!("{d} <{}>", self.email)
102 } else {
103 format!("{encoded} <{}>", self.email)
106 }
107 }
108 }
109 }
110}
111
112impl MessageBuilder {
113 pub fn new() -> Self {
115 Self::default()
116 }
117
118 pub fn from(mut self, addr: impl AsRef<str>) -> Self {
120 self.from = Some(Address::parse(addr.as_ref()));
121 self
122 }
123
124 pub fn reply_to(mut self, addr: impl AsRef<str>) -> Self {
126 self.reply_to = Some(Address::parse(addr.as_ref()));
127 self
128 }
129
130 pub fn to(mut self, addr: impl AsRef<str>) -> Self {
132 self.to.push(Address::parse(addr.as_ref()));
133 self
134 }
135
136 pub fn cc(mut self, addr: impl AsRef<str>) -> Self {
138 self.cc.push(Address::parse(addr.as_ref()));
139 self
140 }
141
142 pub fn bcc(mut self, addr: impl AsRef<str>) -> Self {
146 self.bcc.push(Address::parse(addr.as_ref()));
147 self
148 }
149
150 pub fn subject(mut self, s: impl Into<String>) -> Self {
152 self.subject = Some(s.into());
153 self
154 }
155
156 pub fn date(mut self, s: impl Into<String>) -> Self {
159 self.date = Some(s.into());
160 self
161 }
162
163 pub fn message_id(mut self, s: impl Into<String>) -> Self {
166 self.message_id = Some(s.into());
167 self
168 }
169
170 pub fn text_body(mut self, s: impl Into<String>) -> Self {
172 self.text_body = Some(s.into());
173 self
174 }
175
176 pub fn html_body(mut self, s: impl Into<String>) -> Self {
179 self.html_body = Some(s.into());
180 self
181 }
182
183 pub fn attachment(mut self, att: Attachment) -> Self {
186 self.attachments.push(att);
187 self
188 }
189
190 pub fn header(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
194 self.extra_headers.push((name.into(), value.into()));
195 self
196 }
197
198 pub fn report_type(mut self, kind: impl Into<String>) -> Self {
206 self.report_type = Some(kind.into());
207 self
208 }
209
210 pub fn build_strict(&self) -> Result<Vec<u8>, LintError> {
219 if self.from.is_none() {
221 return Err(LintError::MissingFrom);
222 }
223 if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
224 return Err(LintError::MissingRecipient);
225 }
226 if let Some(mid) = &self.message_id
227 && (!mid.starts_with('<') || !mid.ends_with('>'))
228 {
229 return Err(LintError::BadMessageId(mid.clone()));
230 }
231 for att in &self.attachments {
232 if att.filename.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
233 return Err(LintError::BadAttachmentFilename(att.filename.clone()));
234 }
235 }
236 let bytes = self.build();
237 crate::strict::lint(&bytes)?;
239 Ok(bytes)
240 }
241
242 pub fn build(&self) -> Vec<u8> {
244 let mut out = Vec::new();
245
246 if let Some(f) = &self.from {
248 push_header(&mut out, "From", &f.render());
249 }
250 if let Some(rt) = &self.reply_to {
251 push_header(&mut out, "Reply-To", &rt.render());
252 }
253 if !self.to.is_empty() {
254 push_header(&mut out, "To", &render_address_list(&self.to));
255 }
256 if !self.cc.is_empty() {
257 push_header(&mut out, "Cc", &render_address_list(&self.cc));
258 }
259 if !self.bcc.is_empty() {
260 push_header(&mut out, "Bcc", &render_address_list(&self.bcc));
261 }
262 if let Some(s) = &self.subject {
263 push_header(&mut out, "Subject", &maybe_encode_word(s));
264 }
265 let date_str = match &self.date {
266 Some(d) => d.clone(),
267 None => chrono::Utc::now().format("%a, %d %b %Y %H:%M:%S +0000").to_string(),
268 };
269 push_header(&mut out, "Date", &date_str);
270 if let Some(mid) = &self.message_id {
271 push_header(&mut out, "Message-ID", mid);
272 }
273 for (name, value) in &self.extra_headers {
274 let encoded = maybe_encode_word(value);
275 push_header(&mut out, name, &encoded);
276 }
277 push_header(&mut out, "MIME-Version", "1.0");
278
279 let has_attachments = !self.attachments.is_empty();
281 let has_alternative = self.text_body.is_some() && self.html_body.is_some();
282
283 let _ = has_alternative; if has_attachments {
285 self.render_multipart_mixed(&mut out);
286 } else if has_alternative {
287 self.render_multipart_alternative(&mut out);
288 } else {
289 self.render_singlepart(&mut out);
290 }
291
292 out
293 }
294
295 fn render_singlepart(&self, out: &mut Vec<u8>) {
296 let (body_bytes, ct) = if let Some(html) = &self.html_body {
297 (html.as_bytes().to_vec(), "text/html; charset=utf-8")
298 } else {
299 let text = self.text_body.as_deref().unwrap_or("");
300 (text.as_bytes().to_vec(), "text/plain; charset=utf-8")
301 };
302 let cte = choose_cte(&body_bytes);
303 push_header(out, "Content-Type", ct);
304 push_header(out, "Content-Transfer-Encoding", cte.as_str());
305 out.extend_from_slice(b"\r\n");
306 write_encoded_body(out, &body_bytes, cte);
307 }
308
309 fn render_multipart_alternative(&self, out: &mut Vec<u8>) {
310 let text_part = self.text_body.as_deref().unwrap_or("").as_bytes().to_vec();
311 let html_part = self.html_body.as_deref().unwrap_or("").as_bytes().to_vec();
312 let parts = vec![text_part_bytes(&text_part), html_part_bytes(&html_part)];
313 let (boundary, envelope) = multipart_envelope(&parts);
314 push_header(
315 out,
316 "Content-Type",
317 &format!("multipart/alternative; boundary=\"{boundary}\""),
318 );
319 out.extend_from_slice(b"\r\n");
320 out.extend_from_slice(&envelope);
321 }
322
323 fn render_multipart_mixed(&self, out: &mut Vec<u8>) {
324 let body_part = if self.text_body.is_some() && self.html_body.is_some() {
325 let inner_parts = vec![
327 text_part_bytes(self.text_body.as_deref().unwrap_or("").as_bytes()),
328 html_part_bytes(self.html_body.as_deref().unwrap_or("").as_bytes()),
329 ];
330 let (inner_boundary, inner_envelope) = multipart_envelope(&inner_parts);
331 let mut headers = Vec::new();
332 push_header(
333 &mut headers,
334 "Content-Type",
335 &format!("multipart/alternative; boundary=\"{inner_boundary}\""),
336 );
337 PartBytes {
338 headers,
339 body: inner_envelope,
340 }
341 } else if let Some(html) = &self.html_body {
342 html_part_bytes(html.as_bytes())
343 } else {
344 text_part_bytes(self.text_body.as_deref().unwrap_or("").as_bytes())
345 };
346 let mut parts = vec![body_part];
347 for att in &self.attachments {
348 parts.push(attachment_part_bytes(att));
349 }
350 let (boundary, envelope) = multipart_envelope(&parts);
351 let outer_ct = match &self.report_type {
352 Some(rt) => format!("multipart/report; report-type={rt}; boundary=\"{boundary}\""),
353 None => format!("multipart/mixed; boundary=\"{boundary}\""),
354 };
355 push_header(out, "Content-Type", &outer_ct);
356 out.extend_from_slice(b"\r\n");
357 out.extend_from_slice(&envelope);
358 }
359}
360
361impl fmt::Display for MessageBuilder {
362 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363 let bytes = self.build();
364 let s = std::str::from_utf8(&bytes).map_err(|_| fmt::Error)?;
368 f.write_str(s)
369 }
370}
371
372fn render_address_list(addrs: &[Address]) -> String {
373 addrs.iter().map(Address::render).collect::<Vec<_>>().join(", ")
374}
375
376fn text_part_bytes(body: &[u8]) -> PartBytes {
377 let cte = choose_cte(body);
378 let mut headers = Vec::new();
379 push_header(&mut headers, "Content-Type", "text/plain; charset=utf-8");
380 push_header(&mut headers, "Content-Transfer-Encoding", cte.as_str());
381 let mut body_bytes = Vec::new();
382 write_encoded_body(&mut body_bytes, body, cte);
383 PartBytes { headers, body: body_bytes }
384}
385
386fn html_part_bytes(body: &[u8]) -> PartBytes {
387 let cte = choose_cte(body);
388 let mut headers = Vec::new();
389 push_header(&mut headers, "Content-Type", "text/html; charset=utf-8");
390 push_header(&mut headers, "Content-Transfer-Encoding", cte.as_str());
391 let mut body_bytes = Vec::new();
392 write_encoded_body(&mut body_bytes, body, cte);
393 PartBytes { headers, body: body_bytes }
394}
395
396fn attachment_part_bytes(att: &Attachment) -> PartBytes {
397 let ct_lower = att.content_type.to_ascii_lowercase();
405 let cte = if ct_lower.starts_with("text/") || ct_lower.starts_with("message/") {
406 choose_cte(&att.data)
407 } else {
408 ContentTransferEncoding::Base64
409 };
410 let mut headers = Vec::new();
411 push_header(&mut headers, "Content-Type", &att.content_type);
412 push_header(&mut headers, "Content-Transfer-Encoding", cte.as_str());
413 push_header(
414 &mut headers,
415 "Content-Disposition",
416 &format!("attachment; filename=\"{}\"", att.filename.replace('"', "")),
417 );
418 let mut body = Vec::new();
419 write_encoded_body(&mut body, &att.data, cte);
420 PartBytes { headers, body }
421}
422
423fn push_header(out: &mut Vec<u8>, name: &str, value: &str) {
424 let line = fold_header(name, value);
425 out.extend_from_slice(line.as_bytes());
426 out.extend_from_slice(b"\r\n");
427}
428
429fn write_encoded_body(out: &mut Vec<u8>, body: &[u8], cte: ContentTransferEncoding) {
430 match cte {
431 ContentTransferEncoding::SevenBit | ContentTransferEncoding::EightBit => {
432 out.extend_from_slice(body);
433 if !body.ends_with(b"\r\n") && !body.is_empty() {
434 out.extend_from_slice(b"\r\n");
435 }
436 }
437 ContentTransferEncoding::QuotedPrintable => {
438 out.extend_from_slice(encode_quoted_printable(body).as_bytes());
439 if !body.is_empty() {
440 out.extend_from_slice(b"\r\n");
441 }
442 }
443 ContentTransferEncoding::Base64 => {
444 out.extend_from_slice(encode_base64(body).as_bytes());
445 }
446 }
447}
448
449#[cfg(test)]
450mod tests {
451 use super::*;
452
453 #[test]
454 fn address_parse_bare_email() {
455 let a = Address::parse("alice@example.com");
456 assert_eq!(a.email, "alice@example.com");
457 assert!(a.display.is_none());
458 }
459
460 #[test]
461 fn address_parse_display_form() {
462 let a = Address::parse("Alice <alice@example.com>");
463 assert_eq!(a.email, "alice@example.com");
464 assert_eq!(a.display.as_deref(), Some("Alice"));
465 }
466
467 #[test]
468 fn address_parse_quoted_display() {
469 let a = Address::parse("\"Alice, the Great\" <alice@example.com>");
470 assert_eq!(a.email, "alice@example.com");
471 assert_eq!(a.display.as_deref(), Some("Alice, the Great"));
472 }
473
474 #[test]
475 fn render_bare_address() {
476 let a = Address::parse("alice@example.com");
477 assert_eq!(a.render(), "alice@example.com");
478 }
479
480 #[test]
481 fn render_display_ascii_no_special() {
482 let a = Address::parse("Alice <alice@example.com>");
483 assert_eq!(a.render(), "Alice <alice@example.com>");
484 }
485
486 #[test]
487 fn render_display_ascii_with_comma_gets_quoted() {
488 let a = Address::parse("Alice, Sr. <alice@example.com>");
489 assert!(a.render().contains("\""));
491 }
492
493 #[test]
494 fn build_minimal_plain_text() {
495 let msg = MessageBuilder::new()
496 .from("alice@example.com")
497 .to("bob@example.com")
498 .subject("hi")
499 .text_body("hello")
500 .date("Wed, 27 May 2026 12:00:00 +0000")
501 .message_id("<m1@example.com>")
502 .build();
503 let s = std::str::from_utf8(&msg).unwrap();
504 assert!(s.contains("From: alice@example.com\r\n"));
505 assert!(s.contains("To: bob@example.com\r\n"));
506 assert!(s.contains("Subject: hi\r\n"));
507 assert!(s.contains("Date: Wed, 27 May 2026 12:00:00 +0000\r\n"));
508 assert!(s.contains("Message-ID: <m1@example.com>\r\n"));
509 assert!(s.contains("MIME-Version: 1.0\r\n"));
510 assert!(s.contains("Content-Type: text/plain; charset=utf-8\r\n"));
511 assert!(s.contains("Content-Transfer-Encoding: 7bit\r\n"));
512 assert!(s.contains("\r\n\r\nhello\r\n"));
513 }
514
515 #[test]
516 fn build_subject_non_ascii_uses_encoded_word() {
517 let msg = MessageBuilder::new()
518 .from("alice@example.com")
519 .to("bob@example.com")
520 .subject("こんにちは")
521 .text_body("hello")
522 .build();
523 let s = std::str::from_utf8(&msg).unwrap();
524 let subj_line = s.lines().find(|l| l.starts_with("Subject: ")).unwrap();
525 assert!(subj_line.contains("=?UTF-8?"));
526 }
527
528 #[test]
529 fn build_default_date_is_present() {
530 let msg = MessageBuilder::new()
531 .from("a@x")
532 .to("b@y")
533 .subject("s")
534 .text_body("hi")
535 .build();
536 let s = std::str::from_utf8(&msg).unwrap();
537 assert!(s.contains("\r\nDate: "));
538 }
539
540 #[test]
541 fn build_high_bit_body_uses_qp() {
542 let msg = MessageBuilder::new()
543 .from("a@x")
544 .to("b@y")
545 .subject("s")
546 .text_body("héllo")
547 .date("Wed, 27 May 2026 12:00:00 +0000")
548 .build();
549 let s = std::str::from_utf8(&msg).unwrap();
550 assert!(s.contains("Content-Transfer-Encoding: quoted-printable\r\n"));
551 assert!(s.contains("h=C3=A9llo"));
552 }
553
554 #[test]
555 fn build_text_plus_html_is_multipart_alternative() {
556 let msg = MessageBuilder::new()
557 .from("a@x")
558 .to("b@y")
559 .subject("s")
560 .text_body("hello")
561 .html_body("<p>hello</p>")
562 .date("Wed, 27 May 2026 12:00:00 +0000")
563 .build();
564 let s = std::str::from_utf8(&msg).unwrap();
565 assert!(s.contains("Content-Type: multipart/alternative;"));
566 assert!(s.contains("text/plain"));
567 assert!(s.contains("text/html"));
568 assert!(s.contains("hello"));
569 assert!(s.contains("<p>hello</p>"));
570 }
571
572 #[test]
573 fn build_with_attachment_is_multipart_mixed() {
574 let msg = MessageBuilder::new()
575 .from("a@x")
576 .to("b@y")
577 .subject("s")
578 .text_body("hello")
579 .attachment(Attachment::new("doc.pdf", "application/pdf", vec![0xFF, 0xD8, 0xFF, 0xE0]))
580 .date("Wed, 27 May 2026 12:00:00 +0000")
581 .build();
582 let s = std::str::from_utf8(&msg).unwrap();
583 assert!(s.contains("Content-Type: multipart/mixed;"));
584 assert!(s.contains("application/pdf"));
585 assert!(s.contains("Content-Disposition: attachment; filename=\"doc.pdf\""));
586 assert!(s.contains("Content-Transfer-Encoding: base64\r\n"));
587 }
588
589 #[test]
590 fn display_matches_build() {
591 let mb = MessageBuilder::new()
592 .from("a@x")
593 .to("b@y")
594 .subject("s")
595 .text_body("hi")
596 .date("Wed, 27 May 2026 12:00:00 +0000");
597 let from_display = format!("{mb}");
598 let from_build = std::str::from_utf8(&mb.build()).unwrap().to_string();
599 assert_eq!(from_display, from_build);
600 }
601
602 #[test]
603 fn build_with_cc_bcc() {
604 let msg = MessageBuilder::new()
605 .from("a@x")
606 .to("b@y")
607 .cc("c@y")
608 .bcc("d@y")
609 .subject("s")
610 .text_body("hi")
611 .date("Wed, 27 May 2026 12:00:00 +0000")
612 .build();
613 let s = std::str::from_utf8(&msg).unwrap();
614 assert!(s.contains("To: b@y\r\n"));
615 assert!(s.contains("Cc: c@y\r\n"));
616 assert!(s.contains("Bcc: d@y\r\n"));
617 }
618
619 #[test]
620 fn report_type_switches_outer_to_multipart_report() {
621 let msg = MessageBuilder::new()
622 .from("postmaster@mail.example.com")
623 .to("alice@example.com")
624 .subject("DSN")
625 .text_body("Your message could not be delivered.\r\n")
626 .attachment(Attachment::new(
627 "delivery-status.txt",
628 "message/delivery-status",
629 b"Reporting-MTA: dns; relay\r\n".to_vec(),
630 ))
631 .report_type("delivery-status")
632 .date("Wed, 27 May 2026 12:00:00 +0000")
633 .build();
634 let s = std::str::from_utf8(&msg).unwrap();
635 let unfold = s.replace("\r\n ", " ").replace("\r\n\t", " ");
636 assert!(unfold.contains("Content-Type: multipart/report; report-type=delivery-status;"));
637 assert!(!unfold.contains("multipart/mixed"));
638 }
639
640 #[test]
641 fn no_report_type_keeps_multipart_mixed() {
642 let msg = MessageBuilder::new()
643 .from("a@x")
644 .to("b@y")
645 .subject("s")
646 .text_body("body")
647 .attachment(Attachment::new("a.bin", "application/octet-stream", vec![1]))
648 .date("Wed, 27 May 2026 12:00:00 +0000")
649 .build();
650 let s = std::str::from_utf8(&msg).unwrap();
651 let unfold = s.replace("\r\n ", " ").replace("\r\n\t", " ");
652 assert!(unfold.contains("multipart/mixed"));
653 }
654
655 #[test]
656 fn build_extra_header() {
657 let msg = MessageBuilder::new()
658 .from("a@x")
659 .to("b@y")
660 .subject("s")
661 .text_body("hi")
662 .header("X-Mailer", "mailrs-mail-builder")
663 .date("Wed, 27 May 2026 12:00:00 +0000")
664 .build();
665 let s = std::str::from_utf8(&msg).unwrap();
666 assert!(s.contains("X-Mailer: mailrs-mail-builder\r\n"));
667 }
668}