Skip to main content

mailrs_mail_builder/
builder.rs

1//! `MessageBuilder` — the public face of the crate.
2
3use 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/// One attachment: filename + content-type + raw bytes. The
13/// builder picks the CTE automatically (almost always `base64`) and
14/// emits a `Content-Disposition: attachment; filename="..."` header.
15#[derive(Debug, Clone)]
16pub struct Attachment {
17    /// Filename as it should appear in the `Content-Disposition` header.
18    pub filename: String,
19    /// MIME content-type (e.g. `"application/pdf"`).
20    pub content_type: String,
21    /// Raw bytes (will be base64-encoded into the message).
22    pub data: Vec<u8>,
23}
24
25impl Attachment {
26    /// Construct an attachment.
27    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/// Builder for outbound RFC 5322 messages.
37///
38/// Construction is chain-style: `MessageBuilder::new().from(..).to(..).subject(..)`.
39/// Call [`build`](Self::build) for the raw bytes or use the
40/// [`Display`] impl for a UTF-8 string view.
41#[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    /// When set, the multipart container subtype switches from
56    /// `mixed` to `report` and the outer `Content-Type:` carries a
57    /// `report-type=<value>` parameter (RFC 6522 / 3464). Required
58    /// for DSN bounces (`delivery-status`), DMARC failure reports
59    /// (`disposition-notification`), and TLSRPT (`tlsrpt`).
60    report_type: Option<String>,
61}
62
63/// Single mailbox address with optional display name. Internal-only;
64/// not part of the public API — pass addresses to setters as `&str`.
65#[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        // Try "Display Name <email@host>" form first.
75        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        // Bare address form.
86        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                    // encoded-word; encoded-words may NOT appear
104                    // inside quoted-string, so emit raw.
105                    format!("{encoded} <{}>", self.email)
106                }
107            }
108        }
109    }
110}
111
112impl MessageBuilder {
113    /// Empty builder. All fields default-empty.
114    pub fn new() -> Self {
115        Self::default()
116    }
117
118    /// Set `From:` (single mailbox).
119    pub fn from(mut self, addr: impl AsRef<str>) -> Self {
120        self.from = Some(Address::parse(addr.as_ref()));
121        self
122    }
123
124    /// Set `Reply-To:` (single mailbox).
125    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    /// Append one `To:` recipient.
131    pub fn to(mut self, addr: impl AsRef<str>) -> Self {
132        self.to.push(Address::parse(addr.as_ref()));
133        self
134    }
135
136    /// Append one `Cc:` recipient.
137    pub fn cc(mut self, addr: impl AsRef<str>) -> Self {
138        self.cc.push(Address::parse(addr.as_ref()));
139        self
140    }
141
142    /// Append one `Bcc:` recipient. Bcc is emitted into the message
143    /// body so a downstream MTA strips it before relay; callers
144    /// emitting via the SMTP envelope should NOT add the bcc here.
145    pub fn bcc(mut self, addr: impl AsRef<str>) -> Self {
146        self.bcc.push(Address::parse(addr.as_ref()));
147        self
148    }
149
150    /// Set `Subject:`. Non-ASCII values are RFC 2047 encoded.
151    pub fn subject(mut self, s: impl Into<String>) -> Self {
152        self.subject = Some(s.into());
153        self
154    }
155
156    /// Set `Date:`. If omitted, `build()` fills in the current UTC
157    /// in RFC 5322 §3.3 format.
158    pub fn date(mut self, s: impl Into<String>) -> Self {
159        self.date = Some(s.into());
160        self
161    }
162
163    /// Set `Message-ID:`. Must include the angle brackets
164    /// (e.g. `<abc@example.com>`).
165    pub fn message_id(mut self, s: impl Into<String>) -> Self {
166        self.message_id = Some(s.into());
167        self
168    }
169
170    /// Set the text/plain body.
171    pub fn text_body(mut self, s: impl Into<String>) -> Self {
172        self.text_body = Some(s.into());
173        self
174    }
175
176    /// Set the text/html body. If both `text_body` and `html_body`
177    /// are set, the message becomes `multipart/alternative`.
178    pub fn html_body(mut self, s: impl Into<String>) -> Self {
179        self.html_body = Some(s.into());
180        self
181    }
182
183    /// Append an attachment. Adding any attachment promotes the
184    /// message to `multipart/mixed`.
185    pub fn attachment(mut self, att: Attachment) -> Self {
186        self.attachments.push(att);
187        self
188    }
189
190    /// Add an arbitrary extra header. Use sparingly — almost every
191    /// standard header has a typed setter above. Header values are
192    /// folded and encoded-word-protected automatically.
193    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    /// Set the multipart report-type per RFC 6522. With this set,
199    /// any multipart container the builder emits uses
200    /// `multipart/report; report-type=<value>; boundary=...` instead
201    /// of `multipart/mixed`. Required for DSN
202    /// (`report_type("delivery-status")`), MDN
203    /// (`report_type("disposition-notification")`), and TLSRPT
204    /// (`report_type("tlsrpt")`).
205    pub fn report_type(mut self, kind: impl Into<String>) -> Self {
206        self.report_type = Some(kind.into());
207        self
208    }
209
210    /// Render the message to raw bytes after running the strict-mode
211    /// invariant checks. Returns the first failure if any.
212    ///
213    /// Use this when you want compile-time-level confidence that the
214    /// outbound bytes meet RFC 5322 / 2045 invariants — e.g. before
215    /// handing them to a DKIM signer that would silently sign a
216    /// non-compliant message, or before publishing them as a
217    /// stalwart-grade artifact in a corpus.
218    pub fn build_strict(&self) -> Result<Vec<u8>, LintError> {
219        // Pre-build checks that don't need the rendered bytes.
220        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        // Post-build structural checks (line lengths, bare LF, etc.)
238        crate::strict::lint(&bytes)?;
239        Ok(bytes)
240    }
241
242    /// Render the message to raw bytes.
243    pub fn build(&self) -> Vec<u8> {
244        let mut out = Vec::new();
245
246        // === headers ===
247        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        // === body structure ===
280        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; // multipart-mixed handler re-checks both bodies internally
284        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            // nest multipart/alternative as the first part of mixed
326            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        // mail builder output is always ASCII at the wire level
365        // (encoded-words + qp/base64 ensure no high bits); a
366        // lossless utf8 conversion is the right contract.
367        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    // Attachment CTE choice is content-type-driven:
398    // - text/*, message/* parts route through choose_cte so e.g.
399    //   the message/delivery-status body inside a DSN comes
400    //   through as 7bit ASCII (RFC 3464 §2), not base64
401    // - everything else (application/*, image/*, audio/*, …) is
402    //   treated as binary and forced to base64 regardless of how
403    //   the bytes happen to shape up
404    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        // parse trims at the < boundary; the comma sits in the display half
490        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}