Skip to main content

daaki_message/builder/
mod.rs

1//! RFC 5322 email message builder.
2//!
3//! Constructs email message bytes from structured input for sending via SMTP
4//! or saving as drafts via IMAP `APPEND`.
5//!
6//! # References
7//! - RFC 5322 (Internet Message Format)
8//! - RFC 2045 (MIME Part One — Content-Transfer-Encoding)
9//! - RFC 2046 (MIME Part Two — multipart boundaries)
10//! - RFC 2183 (Content-Disposition: attachment)
11
12mod address;
13mod headers;
14mod ids;
15
16#[cfg(test)]
17#[path = "tests.rs"]
18mod tests;
19
20// Re-export pub(crate) items that other crate modules depend on.
21pub(crate) use address::encode_rfc2047_if_needed;
22pub(crate) use address::validate_address;
23
24use std::sync::atomic::AtomicU64;
25#[cfg(test)]
26use std::sync::atomic::Ordering;
27
28use base64::Engine as _;
29
30use crate::error::Error;
31use crate::parser::{parse_address_list, parse_rfc5322_date, strip_comments};
32use crate::types::{
33    is_strict_bare_message_id_body, Address, BuiltMessage, DateTime, HeaderName,
34    OutgoingAttachment, OutgoingEmail,
35};
36
37// Import sub-module items used by the orchestration in this file and by
38// sibling sub-files via `use super::*;`.
39use address::{
40    escape_quoted_string, extract_domain, format_address, format_address_list,
41    is_resent_extra_header, is_structured_extra_header, is_trace_extra_header, is_valid_msg_id,
42    normalize_line_endings, partition_resent_blocks, resent_field_kind, sanitize_header_value,
43    strip_angle_brackets, validate_resent_header_value, validate_reserved_header_name,
44    validate_trace_header_value, validate_trace_headers,
45};
46use headers::{try_write_header, write_attachment_part, write_boundary, write_text_part};
47use ids::{generate_boundary_not_in, generate_message_id};
48
49// Items used only by the test module — suppress unused-import warnings in
50// non-test builds.
51#[cfg(test)]
52use headers::{
53    encode_quoted_printable, is_trailing_whitespace, is_valid_mime_type, split_header_words,
54};
55#[cfg(test)]
56use ids::{contains_boundary, generate_boundary};
57
58/// Atomic counter for Message-ID uniqueness.
59static MSG_ID_COUNTER: AtomicU64 = AtomicU64::new(0);
60
61/// RFC 5322 Section 3.6.6 resent fields are unique within a single resent
62/// block and may appear in any grouped order.
63#[derive(Clone)]
64struct PendingResentHeader {
65    name: String,
66    value: String,
67    kind: ResentFieldKind,
68}
69
70/// RFC 5322 Sections 3.6.6 and 4.5.6 define the allowed resent field names,
71/// each of which may occur at most once per resent block.
72#[derive(Clone, Copy)]
73enum ResentFieldKind {
74    Date,
75    From { mailbox_count: usize },
76    Sender,
77    To,
78    Cc,
79    Bcc,
80    ReplyTo,
81    MessageId,
82}
83
84impl ResentFieldKind {
85    /// RFC 5322 Sections 3.6.6 and 4.5.6 permit at most one instance of each
86    /// resent field per block, so block partitioning tracks duplicates by
87    /// field slot.
88    const fn slot_index(self) -> usize {
89        match self {
90            Self::Date => 0,
91            Self::From { .. } => 1,
92            Self::Sender => 2,
93            Self::To => 3,
94            Self::Cc => 4,
95            Self::Bcc => 5,
96            Self::ReplyTo => 6,
97            Self::MessageId => 7,
98        }
99    }
100}
101
102/// RFC 5322 Section 3.6.6 requires both `Resent-Date` and `Resent-From` in
103/// each resent block, and requires `Resent-Sender` for multi-mailbox senders.
104#[derive(Clone, Copy)]
105enum ResentBlockError {
106    MissingRequiredFields,
107    MissingSender,
108}
109
110// ---------------------------------------------------------------------------
111// Shared constants
112// ---------------------------------------------------------------------------
113
114/// Conventional maximum line length before folding (RFC 5322 Section 2.1.1).
115///
116/// RFC 5322 Section 2.1.1: "Each line of characters MUST be no more than 998
117/// characters [...] excluding the CRLF." The 78-character limit is a widely
118/// adopted convention for readability, not a normative requirement.
119const MAX_LINE_LEN: usize = 78;
120
121/// Maximum line length for headers containing RFC 2047 encoded-words.
122///
123/// RFC 2047 Section 2: "each line of a header field that contains one or
124/// more 'encoded-word's is limited to 76 characters."
125/// RFC 2047 Section 7 (Conformance) elevates this to a MUST.
126const RFC2047_LINE_LIMIT: usize = 76;
127
128/// Hard maximum line length (RFC 5322 Section 2.1.1 MUST NOT exceed 998).
129const HARD_LINE_LIMIT: usize = 998;
130
131// ---------------------------------------------------------------------------
132// Shared UTF-8 helpers
133// ---------------------------------------------------------------------------
134
135/// Returns `true` if the byte is a UTF-8 continuation byte (0x80..0xBF).
136///
137/// Finds the largest chunk end ≤ `pos + max_bytes` that lands on a UTF-8
138/// character boundary within `bytes`. If even one character does not fit,
139/// the chunk is expanded to include the complete character to avoid an
140/// infinite loop.
141///
142/// Used by RFC 2047 encoding and header force-folding to avoid splitting
143/// multi-byte UTF-8 characters (RFC 6532 / RFC 5322 Section 2.2.3).
144fn snap_utf8_chunk_end(bytes: &[u8], pos: usize, max_bytes: usize) -> usize {
145    let mut end = (pos + max_bytes).min(bytes.len());
146    // Back up to a character boundary
147    while end > pos && end < bytes.len() && is_utf8_continuation(bytes[end]) {
148        end -= 1;
149    }
150    // If we couldn't fit even one character, advance past the complete character
151    if end == pos && pos < bytes.len() {
152        end = (pos + utf8_char_len(bytes[pos])).min(bytes.len());
153    }
154    end
155}
156
157/// Used to avoid splitting multi-byte UTF-8 characters during header
158/// force-folding (RFC 6532 / RFC 5322 Section 2.2.3).
159pub(crate) fn is_utf8_continuation(b: u8) -> bool {
160    (b & 0xC0) == 0x80
161}
162
163/// Returns the expected byte length of a UTF-8 character from its lead byte.
164///
165/// Returns 1 for ASCII, 2-4 for multi-byte sequences.
166pub(crate) fn utf8_char_len(lead: u8) -> usize {
167    if lead < 0x80 {
168        1
169    } else if lead < 0xE0 {
170        2
171    } else if lead < 0xF0 {
172        3
173    } else {
174        4
175    }
176}
177
178// ---------------------------------------------------------------------------
179// Message building orchestration
180// ---------------------------------------------------------------------------
181
182/// Builds an RFC 5322 message from an [`OutgoingEmail`].
183///
184/// Returns raw bytes, the list of all envelope recipients (to + cc + bcc)
185/// for SMTP `RCPT TO`, and the generated Message-ID.
186///
187/// Text MIME parts always use 7-bit safe encoding: pure ASCII with
188/// conforming lines gets `Content-Transfer-Encoding: 7bit` (RFC 2045
189/// Section 2.7); anything else uses `quoted-printable` (RFC 2045
190/// Section 6.7). This guarantees the built bytes can be sent to any SMTP
191/// server — with or without 8BITMIME — and stored via IMAP `APPEND`
192/// without re-encoding.
193///
194/// BCC addresses are included in [`BuiltMessage::envelope_recipients`] but
195/// are **not** present in the message headers (RFC 5322 Section 3.6.3).
196///
197/// # Errors
198///
199/// Returns [`Error::InvalidAddress`] for syntactically invalid email addresses,
200/// [`Error::HeaderLineTooLong`] for unfoldable header tokens,
201/// [`Error::ReservedHeaderName`] for collisions with builder-managed headers,
202/// [`Error::InvalidAttachment`] for MIME constraint violations,
203/// [`Error::InvalidTraceHeader`] for malformed trace fields, or
204/// [`Error::InvalidResentHeader`] for malformed resent fields.
205///
206/// # References
207/// - RFC 5322 (message format)
208/// - RFC 2046 (MIME multipart)
209/// - RFC 2045 (Content-Transfer-Encoding)
210#[allow(clippy::too_many_lines)]
211pub fn build_message(email: &OutgoingEmail) -> Result<BuiltMessage, Error> {
212    // Validate all addresses (RFC 5322 Section 3.4)
213    if email.from.is_empty() {
214        return Err(Error::MissingFrom);
215    }
216    for addr in &email.from {
217        validate_address(addr)?;
218    }
219    if let Some(ref sender) = email.sender {
220        validate_address(sender)?;
221    }
222    for addr in &email.to {
223        validate_address(addr)?;
224    }
225    for addr in &email.cc {
226        validate_address(addr)?;
227    }
228    for addr in &email.bcc {
229        validate_address(addr)?;
230    }
231    for addr in &email.reply_to {
232        validate_address(addr)?;
233    }
234
235    // RFC 5322 Section 3.6.2: "If the from field contains more than one
236    // mailbox specification in the mailbox-list, then the sender field,
237    // containing the field value corresponding to the responsible agent
238    // of the message, MUST be present in the message."
239    if email.from.len() > 1 && email.sender.is_none() {
240        return Err(Error::MissingSender);
241    }
242
243    // Generate Message-ID (RFC 5322 Section 3.6.4)
244    // Use the sender address for the domain when present, otherwise the
245    // first From address.
246    let domain_addr = email.sender.as_ref().unwrap_or(&email.from[0]);
247    let domain = extract_domain(&domain_addr.email).unwrap_or("daaki.local");
248    let message_id = generate_message_id(domain);
249
250    let mut return_path_headers: Vec<(String, String)> = Vec::new();
251    let mut other_trace_headers: Vec<(String, String)> = Vec::new();
252    let mut pending_resent_headers: Vec<PendingResentHeader> = Vec::new();
253    let mut regular_extra_headers: Vec<(String, String)> = Vec::new();
254
255    // RFC 5321 Section 4.4 and RFC 5322 Section 3.6.6 require trace fields
256    // and resent blocks to be prepended ahead of the original header block.
257    for (name, value) in &email.extra_headers {
258        validate_reserved_header_name(name)?;
259        let name_str = name.as_str();
260        let sanitized = sanitize_header_value(value);
261        validate_trace_header_value(name_str, &sanitized)?;
262        let resent_from_count = validate_resent_header_value(name_str, &sanitized)?;
263        let wire_value = if is_structured_extra_header(name_str) {
264            sanitized
265        } else {
266            encode_rfc2047_if_needed(&sanitized)
267        };
268
269        if is_trace_extra_header(name_str) {
270            if name_str.eq_ignore_ascii_case("return-path") {
271                return_path_headers.push((name_str.to_owned(), wire_value));
272            } else {
273                other_trace_headers.push((name_str.to_owned(), wire_value));
274            }
275        } else if is_resent_extra_header(name_str) {
276            pending_resent_headers.push(PendingResentHeader {
277                name: name_str.to_owned(),
278                value: wire_value,
279                kind: resent_field_kind(name_str, resent_from_count.unwrap_or(0))?,
280            });
281        } else {
282            regular_extra_headers.push((name_str.to_owned(), wire_value));
283        }
284    }
285
286    validate_trace_headers(&return_path_headers, &other_trace_headers)?;
287    let resent_blocks = partition_resent_blocks(&pending_resent_headers)?;
288
289    let mut raw = Vec::new();
290
291    // --- Headers ---
292    // All user-provided values are sanitized to strip CR/LF and prevent
293    // header injection (RFC 5322 Section 2.1).
294
295    // RFC 5321 Section 4.4: Return-Path and Received trace headers are
296    // prepended to the message header block. Return-Path comes first.
297    for (name, value) in &return_path_headers {
298        try_write_header(&mut raw, name, value)?;
299    }
300    for (name, value) in &other_trace_headers {
301        try_write_header(&mut raw, name, value)?;
302    }
303
304    // RFC 5322 Section 3.6.6: each resent block is prepended ahead of the
305    // original header block and kept grouped together.
306    for resent_block in &resent_blocks {
307        for (name, value) in resent_block {
308            try_write_header(&mut raw, name, value)?;
309        }
310    }
311
312    // From (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`)
313    try_write_header(
314        &mut raw,
315        "From",
316        &sanitize_header_value(&format_address_list(&email.from)),
317    )?;
318
319    // Sender (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`)
320    // Emitted when explicitly provided. Required when From has multiple
321    // addresses (validated above). When From has a single address and
322    // Sender is provided, emit only if it differs from the From mailbox
323    // specification
324    // (RFC 5322 Section 3.6.2: "If the from field contains a single
325    // mailbox specification, the sender field SHOULD NOT be present").
326    if let Some(ref sender) = email.sender {
327        let emit_sender = if email.from.len() == 1 {
328            // RFC 5322 Section 3.6.2 distinguishes whole mailbox
329            // specifications, not just addr-specs. A different
330            // display-name still represents a different mailbox
331            // specification and should keep Sender present.
332            sender != &email.from[0]
333        } else {
334            // Multiple From — always emit Sender (required)
335            true
336        };
337        if emit_sender {
338            try_write_header(
339                &mut raw,
340                "Sender",
341                &sanitize_header_value(&format_address(sender)),
342            )?;
343        }
344    }
345
346    // To (RFC 5322 Section 3.6.3)
347    if !email.to.is_empty() {
348        try_write_header(
349            &mut raw,
350            "To",
351            &sanitize_header_value(&format_address_list(&email.to)),
352        )?;
353    }
354
355    // Cc (RFC 5322 Section 3.6.3)
356    if !email.cc.is_empty() {
357        try_write_header(
358            &mut raw,
359            "Cc",
360            &sanitize_header_value(&format_address_list(&email.cc)),
361        )?;
362    }
363
364    // BCC header intentionally omitted — safest option per RFC 5322 Section 3.6.3
365
366    // Reply-To (RFC 5322 Section 3.6.2: address-list)
367    if !email.reply_to.is_empty() {
368        try_write_header(
369            &mut raw,
370            "Reply-To",
371            &sanitize_header_value(&format_address_list(&email.reply_to)),
372        )?;
373    }
374
375    // Subject (RFC 5322 Section 3.6.5, RFC 2047 for non-ASCII)
376    try_write_header(
377        &mut raw,
378        "Subject",
379        &encode_rfc2047_if_needed(&sanitize_header_value(&email.subject)),
380    )?;
381
382    // Date (RFC 5322 Section 3.6.1): use caller-supplied date, or current UTC time.
383    let date = email.date.clone().unwrap_or_else(DateTime::now);
384    try_write_header(&mut raw, "Date", &date.to_rfc5322_string())?;
385
386    // Message-ID (RFC 5322 Section 3.6.4)
387    try_write_header(&mut raw, "Message-ID", &format!("<{message_id}>"))?;
388
389    // MIME-Version (RFC 2045 Section 4)
390    try_write_header(&mut raw, "MIME-Version", "1.0")?;
391
392    // In-Reply-To (RFC 5322 Section 3.6.4): in-reply-to = 1*msg-id,
393    // so multiple message-ids are allowed. Each Vec element is a bare
394    // message-id; wrap each in angle brackets for the wire format.
395    if !email.in_reply_to.is_empty() {
396        let ids: Vec<String> = email
397            .in_reply_to
398            .iter()
399            .filter_map(|id| {
400                let sanitized = sanitize_header_value(id.as_str());
401                // Strip existing angle brackets before wrapping
402                let bare = strip_angle_brackets(&sanitized);
403                // RFC 5322 Section 3.6.4: msg-id = "<" id-left "@" id-right ">"
404                // Both id-left and id-right must be non-empty.
405                // Skip malformed tokens (Postel's law: be conservative in what you send).
406                if is_valid_msg_id(bare) {
407                    Some(format!("<{bare}>"))
408                } else {
409                    None
410                }
411            })
412            .collect();
413        if !ids.is_empty() {
414            try_write_header(&mut raw, "In-Reply-To", &ids.join(" "))?;
415        }
416    }
417
418    // References (RFC 5322 Section 3.6.4): references = 1*msg-id.
419    // Each Vec element is a bare message-id; wrap each in angle brackets.
420    if !email.references.is_empty() {
421        let refs: Vec<String> = email
422            .references
423            .iter()
424            .filter_map(|id| {
425                let sanitized = sanitize_header_value(id.as_str());
426                // Strip existing angle brackets before wrapping
427                let bare = strip_angle_brackets(&sanitized);
428                // RFC 5322 Section 3.6.4: msg-id = "<" id-left "@" id-right ">"
429                // Both id-left and id-right must be non-empty.
430                if is_valid_msg_id(bare) {
431                    Some(format!("<{bare}>"))
432                } else {
433                    None
434                }
435            })
436            .collect();
437        if !refs.is_empty() {
438            try_write_header(&mut raw, "References", &refs.join(" "))?;
439        }
440    }
441
442    // Extra optional fields that are neither trace fields nor resent fields
443    // follow the originator / identification block (RFC 5322 Section 3.6.8).
444    for (name, value) in &regular_extra_headers {
445        try_write_header(&mut raw, name, value)?;
446    }
447
448    // --- Body ---
449
450    let has_text = email.body_text.is_some();
451    let has_html = email.body_html.is_some();
452
453    // RFC 2387: inline attachments with a Content-ID that are referenced by
454    // the HTML body (via `cid:` URLs) must be grouped with the HTML in a
455    // `multipart/related` container. Separate them from regular attachments.
456    let (inline_atts, regular_atts): (Vec<_>, Vec<_>) = email
457        .attachments
458        .iter()
459        .partition(|a| a.is_inline && a.content_id.is_some());
460    // RFC 2046 Section 5.1: any attachment must produce a multipart message.
461    // Use the original list length rather than the partitioned inline/regular
462    // flags so that inline attachments without an HTML body (no `cid:`
463    // reference target) are still emitted — the re-classification inside the
464    // `if has_attachments` block handles them as regular attachments.
465    let has_attachments = !email.attachments.is_empty();
466
467    // Collect all encapsulated content so that generated boundaries can be
468    // verified not to collide with it (RFC 2046 Section 5.1.1: "The boundary
469    // delimiter MUST NOT appear within the encapsulated material.").
470    let encapsulated_content = {
471        let mut buf = Vec::new();
472        if let Some(ref text) = email.body_text {
473            buf.extend_from_slice(text.as_bytes());
474        }
475        if let Some(ref html) = email.body_html {
476            buf.extend_from_slice(html.as_bytes());
477        }
478        for att in &email.attachments {
479            buf.extend_from_slice(&att.data);
480            buf.extend_from_slice(att.filename.as_bytes());
481        }
482        buf
483    };
484
485    // Track all generated boundaries to ensure uniqueness across nesting
486    // levels (RFC 2046 Section 5.1.1).
487    let mut used_boundaries: Vec<String> = Vec::new();
488    let new_boundary = |content: &[u8], used: &mut Vec<String>| -> String {
489        loop {
490            let b = generate_boundary_not_in(content);
491            if !used.contains(&b) {
492                used.push(b.clone());
493                return b;
494            }
495        }
496    };
497
498    if has_attachments {
499        // When inline attachments exist without HTML (no `cid:` reference
500        // target), treat them as regular attachments.
501        let (inline_atts, regular_atts) = if has_html {
502            (inline_atts, regular_atts)
503        } else {
504            (Vec::new(), email.attachments.iter().collect::<Vec<_>>())
505        };
506        let has_inline = !inline_atts.is_empty();
507        let has_regular = !regular_atts.is_empty();
508        // Outer multipart/mixed is needed when there are regular (non-inline)
509        // attachments, or when there are no inline attachments (all are
510        // regular by default).
511        let needs_mixed = has_regular || !has_inline;
512
513        let mixed_boundary = if needs_mixed {
514            let b = new_boundary(&encapsulated_content, &mut used_boundaries);
515            // Outer multipart/mixed (RFC 2046 Section 5.1.3)
516            try_write_header(
517                &mut raw,
518                "Content-Type",
519                &format!("multipart/mixed; boundary=\"{b}\""),
520            )?;
521            raw.extend_from_slice(b"\r\n");
522            write_boundary(&mut raw, &b, false);
523            Some(b)
524        } else {
525            None
526        };
527
528        if has_inline {
529            // RFC 2387: wrap HTML body + inline attachments in multipart/related.
530            let related_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
531            // RFC 2387 Section 3.1: the `type` parameter is REQUIRED and
532            // specifies the MIME type of the root body part.
533            let root_type = if has_text && has_html {
534                "multipart/alternative"
535            } else {
536                "text/html"
537            };
538            try_write_header(
539                &mut raw,
540                "Content-Type",
541                &format!(
542                    "multipart/related; type=\"{root_type}\"; boundary=\"{related_boundary}\""
543                ),
544            )?;
545            raw.extend_from_slice(b"\r\n");
546            write_boundary(&mut raw, &related_boundary, false);
547
548            // The root part of multipart/related is the HTML body (or
549            // multipart/alternative if both text and HTML are present).
550            if has_text && has_html {
551                let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
552                try_write_header(
553                    &mut raw,
554                    "Content-Type",
555                    &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
556                )?;
557                raw.extend_from_slice(b"\r\n");
558
559                write_boundary(&mut raw, &alt_boundary, false);
560                write_text_part(
561                    &mut raw,
562                    email.body_text.as_deref().unwrap_or(""),
563                    "text/plain",
564                )?;
565                write_boundary(&mut raw, &alt_boundary, false);
566                write_text_part(
567                    &mut raw,
568                    email.body_html.as_deref().unwrap_or(""),
569                    "text/html",
570                )?;
571                write_boundary(&mut raw, &alt_boundary, true);
572            } else {
573                write_text_part(
574                    &mut raw,
575                    email.body_html.as_deref().unwrap_or(""),
576                    "text/html",
577                )?;
578            }
579
580            // Inline attachments inside multipart/related
581            for attachment in &inline_atts {
582                write_boundary(&mut raw, &related_boundary, false);
583                write_attachment_part(&mut raw, attachment)?;
584            }
585
586            write_boundary(&mut raw, &related_boundary, true);
587        } else {
588            // No inline attachments — write body part(s) directly
589            if has_text && has_html {
590                let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
591                try_write_header(
592                    &mut raw,
593                    "Content-Type",
594                    &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
595                )?;
596                raw.extend_from_slice(b"\r\n");
597
598                write_boundary(&mut raw, &alt_boundary, false);
599                write_text_part(
600                    &mut raw,
601                    email.body_text.as_deref().unwrap_or(""),
602                    "text/plain",
603                )?;
604                write_boundary(&mut raw, &alt_boundary, false);
605                write_text_part(
606                    &mut raw,
607                    email.body_html.as_deref().unwrap_or(""),
608                    "text/html",
609                )?;
610                write_boundary(&mut raw, &alt_boundary, true);
611            } else if has_text {
612                write_text_part(
613                    &mut raw,
614                    email.body_text.as_deref().unwrap_or(""),
615                    "text/plain",
616                )?;
617            } else if has_html {
618                write_text_part(
619                    &mut raw,
620                    email.body_html.as_deref().unwrap_or(""),
621                    "text/html",
622                )?;
623            } else {
624                write_text_part(&mut raw, "", "text/plain")?;
625            }
626        }
627
628        // Regular (non-inline) attachment parts in multipart/mixed
629        if let Some(ref mixed_b) = mixed_boundary {
630            for attachment in &regular_atts {
631                write_boundary(&mut raw, mixed_b, false);
632                write_attachment_part(&mut raw, attachment)?;
633            }
634            write_boundary(&mut raw, mixed_b, true);
635        }
636    } else if has_text && has_html {
637        // multipart/alternative (RFC 2046 Section 5.1.4)
638        let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
639        try_write_header(
640            &mut raw,
641            "Content-Type",
642            &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
643        )?;
644        raw.extend_from_slice(b"\r\n");
645
646        write_boundary(&mut raw, &alt_boundary, false);
647        write_text_part(
648            &mut raw,
649            email.body_text.as_deref().unwrap_or(""),
650            "text/plain",
651        )?;
652
653        write_boundary(&mut raw, &alt_boundary, false);
654        write_text_part(
655            &mut raw,
656            email.body_html.as_deref().unwrap_or(""),
657            "text/html",
658        )?;
659
660        write_boundary(&mut raw, &alt_boundary, true);
661    } else if has_html {
662        // Single text/html — delegate to write_text_part which handles
663        // quoted-printable fallback for long lines (RFC 2045 Section 2.8).
664        write_text_part(
665            &mut raw,
666            email.body_html.as_deref().unwrap_or(""),
667            "text/html",
668        )?;
669    } else {
670        // Single text/plain or empty body — delegate to write_text_part which
671        // handles quoted-printable fallback for long lines (RFC 2045 Section 2.8).
672        write_text_part(
673            &mut raw,
674            email.body_text.as_deref().unwrap_or(""),
675            "text/plain",
676        )?;
677    }
678
679    // Collect envelope recipients (to + cc + bcc) for SMTP RCPT TO
680    let mut envelope_recipients: Vec<String> = email.to.iter().map(|a| a.email.clone()).collect();
681    envelope_recipients.extend(email.cc.iter().map(|a| a.email.clone()));
682    envelope_recipients.extend(email.bcc.iter().map(|a| a.email.clone()));
683
684    Ok(BuiltMessage {
685        raw,
686        envelope_recipients,
687        message_id,
688    })
689}