daaki-message 0.2.0

RFC 5322 email message parser and builder
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
//! RFC 5322 email message builder.
//!
//! Constructs email message bytes from structured input for sending via SMTP
//! or saving as drafts via IMAP `APPEND`.
//!
//! # References
//! - RFC 5322 (Internet Message Format)
//! - RFC 2045 (MIME Part One — Content-Transfer-Encoding)
//! - RFC 2046 (MIME Part Two — multipart boundaries)
//! - RFC 2183 (Content-Disposition: attachment)

mod address;
mod headers;
mod ids;

#[cfg(test)]
#[path = "tests.rs"]
mod tests;

// Re-export pub(crate) items that other crate modules depend on.
pub(crate) use address::encode_rfc2047_if_needed;
pub(crate) use address::validate_address;

use std::sync::atomic::AtomicU64;
#[cfg(test)]
use std::sync::atomic::Ordering;

use base64::Engine as _;

use crate::error::Error;
use crate::parser::{parse_address_list, parse_rfc5322_date, strip_comments};
use crate::types::{
    is_strict_bare_message_id_body, Address, BuiltMessage, DateTime, HeaderName,
    OutgoingAttachment, OutgoingEmail,
};

// Import sub-module items used by the orchestration in this file and by
// sibling sub-files via `use super::*;`.
use address::{
    escape_quoted_string, extract_domain, format_address, format_address_list,
    is_resent_extra_header, is_structured_extra_header, is_trace_extra_header, is_valid_msg_id,
    normalize_line_endings, partition_resent_blocks, resent_field_kind, sanitize_header_value,
    strip_angle_brackets, validate_resent_header_value, validate_reserved_header_name,
    validate_trace_header_value, validate_trace_headers,
};
use headers::{try_write_header, write_attachment_part, write_boundary, write_text_part};
use ids::{generate_boundary_not_in, generate_message_id};

// Items used only by the test module — suppress unused-import warnings in
// non-test builds.
#[cfg(test)]
use headers::{
    encode_quoted_printable, is_trailing_whitespace, is_valid_mime_type, split_header_words,
};
#[cfg(test)]
use ids::{contains_boundary, generate_boundary};

/// Atomic counter for Message-ID uniqueness.
static MSG_ID_COUNTER: AtomicU64 = AtomicU64::new(0);

/// RFC 5322 Section 3.6.6 resent fields are unique within a single resent
/// block and may appear in any grouped order.
#[derive(Clone)]
struct PendingResentHeader {
    name: String,
    value: String,
    kind: ResentFieldKind,
}

/// RFC 5322 Sections 3.6.6 and 4.5.6 define the allowed resent field names,
/// each of which may occur at most once per resent block.
#[derive(Clone, Copy)]
enum ResentFieldKind {
    Date,
    From { mailbox_count: usize },
    Sender,
    To,
    Cc,
    Bcc,
    ReplyTo,
    MessageId,
}

impl ResentFieldKind {
    /// RFC 5322 Sections 3.6.6 and 4.5.6 permit at most one instance of each
    /// resent field per block, so block partitioning tracks duplicates by
    /// field slot.
    const fn slot_index(self) -> usize {
        match self {
            Self::Date => 0,
            Self::From { .. } => 1,
            Self::Sender => 2,
            Self::To => 3,
            Self::Cc => 4,
            Self::Bcc => 5,
            Self::ReplyTo => 6,
            Self::MessageId => 7,
        }
    }
}

/// RFC 5322 Section 3.6.6 requires both `Resent-Date` and `Resent-From` in
/// each resent block, and requires `Resent-Sender` for multi-mailbox senders.
#[derive(Clone, Copy)]
enum ResentBlockError {
    MissingRequiredFields,
    MissingSender,
}

// ---------------------------------------------------------------------------
// Shared constants
// ---------------------------------------------------------------------------

/// Conventional maximum line length before folding (RFC 5322 Section 2.1.1).
///
/// RFC 5322 Section 2.1.1: "Each line of characters MUST be no more than 998
/// characters [...] excluding the CRLF." The 78-character limit is a widely
/// adopted convention for readability, not a normative requirement.
const MAX_LINE_LEN: usize = 78;

/// Maximum line length for headers containing RFC 2047 encoded-words.
///
/// RFC 2047 Section 2: "each line of a header field that contains one or
/// more 'encoded-word's is limited to 76 characters."
/// RFC 2047 Section 7 (Conformance) elevates this to a MUST.
const RFC2047_LINE_LIMIT: usize = 76;

/// Hard maximum line length (RFC 5322 Section 2.1.1 MUST NOT exceed 998).
const HARD_LINE_LIMIT: usize = 998;

// ---------------------------------------------------------------------------
// Shared UTF-8 helpers
// ---------------------------------------------------------------------------

/// Returns `true` if the byte is a UTF-8 continuation byte (0x80..0xBF).
///
/// Finds the largest chunk end ≤ `pos + max_bytes` that lands on a UTF-8
/// character boundary within `bytes`. If even one character does not fit,
/// the chunk is expanded to include the complete character to avoid an
/// infinite loop.
///
/// Used by RFC 2047 encoding and header force-folding to avoid splitting
/// multi-byte UTF-8 characters (RFC 6532 / RFC 5322 Section 2.2.3).
fn snap_utf8_chunk_end(bytes: &[u8], pos: usize, max_bytes: usize) -> usize {
    let mut end = (pos + max_bytes).min(bytes.len());
    // Back up to a character boundary
    while end > pos && end < bytes.len() && is_utf8_continuation(bytes[end]) {
        end -= 1;
    }
    // If we couldn't fit even one character, advance past the complete character
    if end == pos && pos < bytes.len() {
        end = (pos + utf8_char_len(bytes[pos])).min(bytes.len());
    }
    end
}

/// Used to avoid splitting multi-byte UTF-8 characters during header
/// force-folding (RFC 6532 / RFC 5322 Section 2.2.3).
pub(crate) fn is_utf8_continuation(b: u8) -> bool {
    (b & 0xC0) == 0x80
}

/// Returns the expected byte length of a UTF-8 character from its lead byte.
///
/// Returns 1 for ASCII, 2-4 for multi-byte sequences.
pub(crate) fn utf8_char_len(lead: u8) -> usize {
    if lead < 0x80 {
        1
    } else if lead < 0xE0 {
        2
    } else if lead < 0xF0 {
        3
    } else {
        4
    }
}

// ---------------------------------------------------------------------------
// Message building orchestration
// ---------------------------------------------------------------------------

/// Builds an RFC 5322 message from an [`OutgoingEmail`].
///
/// Returns raw bytes, the list of all envelope recipients (to + cc + bcc)
/// for SMTP `RCPT TO`, and the generated Message-ID.
///
/// Text MIME parts always use 7-bit safe encoding: pure ASCII with
/// conforming lines gets `Content-Transfer-Encoding: 7bit` (RFC 2045
/// Section 2.7); anything else uses `quoted-printable` (RFC 2045
/// Section 6.7). This guarantees the built bytes can be sent to any SMTP
/// server — with or without 8BITMIME — and stored via IMAP `APPEND`
/// without re-encoding.
///
/// BCC addresses are included in [`BuiltMessage::envelope_recipients`] but
/// are **not** present in the message headers (RFC 5322 Section 3.6.3).
///
/// # Errors
///
/// Returns [`Error::InvalidAddress`] for syntactically invalid email addresses,
/// [`Error::HeaderLineTooLong`] for unfoldable header tokens,
/// [`Error::ReservedHeaderName`] for collisions with builder-managed headers,
/// [`Error::InvalidAttachment`] for MIME constraint violations,
/// [`Error::InvalidTraceHeader`] for malformed trace fields, or
/// [`Error::InvalidResentHeader`] for malformed resent fields.
///
/// # References
/// - RFC 5322 (message format)
/// - RFC 2046 (MIME multipart)
/// - RFC 2045 (Content-Transfer-Encoding)
#[allow(clippy::too_many_lines)]
pub fn build_message(email: &OutgoingEmail) -> Result<BuiltMessage, Error> {
    // Validate all addresses (RFC 5322 Section 3.4)
    if email.from.is_empty() {
        return Err(Error::MissingFrom);
    }
    for addr in &email.from {
        validate_address(addr)?;
    }
    if let Some(ref sender) = email.sender {
        validate_address(sender)?;
    }
    for addr in &email.to {
        validate_address(addr)?;
    }
    for addr in &email.cc {
        validate_address(addr)?;
    }
    for addr in &email.bcc {
        validate_address(addr)?;
    }
    for addr in &email.reply_to {
        validate_address(addr)?;
    }

    // RFC 5322 Section 3.6.2: "If the from field contains more than one
    // mailbox specification in the mailbox-list, then the sender field,
    // containing the field value corresponding to the responsible agent
    // of the message, MUST be present in the message."
    if email.from.len() > 1 && email.sender.is_none() {
        return Err(Error::MissingSender);
    }

    // Generate Message-ID (RFC 5322 Section 3.6.4)
    // Use the sender address for the domain when present, otherwise the
    // first From address.
    let domain_addr = email.sender.as_ref().unwrap_or(&email.from[0]);
    let domain = extract_domain(&domain_addr.email).unwrap_or("daaki.local");
    let message_id = generate_message_id(domain);

    let mut return_path_headers: Vec<(String, String)> = Vec::new();
    let mut other_trace_headers: Vec<(String, String)> = Vec::new();
    let mut pending_resent_headers: Vec<PendingResentHeader> = Vec::new();
    let mut regular_extra_headers: Vec<(String, String)> = Vec::new();

    // RFC 5321 Section 4.4 and RFC 5322 Section 3.6.6 require trace fields
    // and resent blocks to be prepended ahead of the original header block.
    for (name, value) in &email.extra_headers {
        validate_reserved_header_name(name)?;
        let name_str = name.as_str();
        let sanitized = sanitize_header_value(value);
        validate_trace_header_value(name_str, &sanitized)?;
        let resent_from_count = validate_resent_header_value(name_str, &sanitized)?;
        let wire_value = if is_structured_extra_header(name_str) {
            sanitized
        } else {
            encode_rfc2047_if_needed(&sanitized)
        };

        if is_trace_extra_header(name_str) {
            if name_str.eq_ignore_ascii_case("return-path") {
                return_path_headers.push((name_str.to_owned(), wire_value));
            } else {
                other_trace_headers.push((name_str.to_owned(), wire_value));
            }
        } else if is_resent_extra_header(name_str) {
            pending_resent_headers.push(PendingResentHeader {
                name: name_str.to_owned(),
                value: wire_value,
                kind: resent_field_kind(name_str, resent_from_count.unwrap_or(0))?,
            });
        } else {
            regular_extra_headers.push((name_str.to_owned(), wire_value));
        }
    }

    validate_trace_headers(&return_path_headers, &other_trace_headers)?;
    let resent_blocks = partition_resent_blocks(&pending_resent_headers)?;

    let mut raw = Vec::new();

    // --- Headers ---
    // All user-provided values are sanitized to strip CR/LF and prevent
    // header injection (RFC 5322 Section 2.1).

    // RFC 5321 Section 4.4: Return-Path and Received trace headers are
    // prepended to the message header block. Return-Path comes first.
    for (name, value) in &return_path_headers {
        try_write_header(&mut raw, name, value)?;
    }
    for (name, value) in &other_trace_headers {
        try_write_header(&mut raw, name, value)?;
    }

    // RFC 5322 Section 3.6.6: each resent block is prepended ahead of the
    // original header block and kept grouped together.
    for resent_block in &resent_blocks {
        for (name, value) in resent_block {
            try_write_header(&mut raw, name, value)?;
        }
    }

    // From (RFC 5322 Section 3.6.2: `from = "From:" mailbox-list`)
    try_write_header(
        &mut raw,
        "From",
        &sanitize_header_value(&format_address_list(&email.from)),
    )?;

    // Sender (RFC 5322 Section 3.6.2: `sender = "Sender:" mailbox`)
    // Emitted when explicitly provided. Required when From has multiple
    // addresses (validated above). When From has a single address and
    // Sender is provided, emit only if it differs from the From mailbox
    // specification
    // (RFC 5322 Section 3.6.2: "If the from field contains a single
    // mailbox specification, the sender field SHOULD NOT be present").
    if let Some(ref sender) = email.sender {
        let emit_sender = if email.from.len() == 1 {
            // RFC 5322 Section 3.6.2 distinguishes whole mailbox
            // specifications, not just addr-specs. A different
            // display-name still represents a different mailbox
            // specification and should keep Sender present.
            sender != &email.from[0]
        } else {
            // Multiple From — always emit Sender (required)
            true
        };
        if emit_sender {
            try_write_header(
                &mut raw,
                "Sender",
                &sanitize_header_value(&format_address(sender)),
            )?;
        }
    }

    // To (RFC 5322 Section 3.6.3)
    if !email.to.is_empty() {
        try_write_header(
            &mut raw,
            "To",
            &sanitize_header_value(&format_address_list(&email.to)),
        )?;
    }

    // Cc (RFC 5322 Section 3.6.3)
    if !email.cc.is_empty() {
        try_write_header(
            &mut raw,
            "Cc",
            &sanitize_header_value(&format_address_list(&email.cc)),
        )?;
    }

    // BCC header intentionally omitted — safest option per RFC 5322 Section 3.6.3

    // Reply-To (RFC 5322 Section 3.6.2: address-list)
    if !email.reply_to.is_empty() {
        try_write_header(
            &mut raw,
            "Reply-To",
            &sanitize_header_value(&format_address_list(&email.reply_to)),
        )?;
    }

    // Subject (RFC 5322 Section 3.6.5, RFC 2047 for non-ASCII)
    try_write_header(
        &mut raw,
        "Subject",
        &encode_rfc2047_if_needed(&sanitize_header_value(&email.subject)),
    )?;

    // Date (RFC 5322 Section 3.6.1): use caller-supplied date, or current UTC time.
    let date = email.date.clone().unwrap_or_else(DateTime::now);
    try_write_header(&mut raw, "Date", &date.to_rfc5322_string())?;

    // Message-ID (RFC 5322 Section 3.6.4)
    try_write_header(&mut raw, "Message-ID", &format!("<{message_id}>"))?;

    // MIME-Version (RFC 2045 Section 4)
    try_write_header(&mut raw, "MIME-Version", "1.0")?;

    // In-Reply-To (RFC 5322 Section 3.6.4): in-reply-to = 1*msg-id,
    // so multiple message-ids are allowed. Each Vec element is a bare
    // message-id; wrap each in angle brackets for the wire format.
    if !email.in_reply_to.is_empty() {
        let ids: Vec<String> = email
            .in_reply_to
            .iter()
            .filter_map(|id| {
                let sanitized = sanitize_header_value(id.as_str());
                // Strip existing angle brackets before wrapping
                let bare = strip_angle_brackets(&sanitized);
                // RFC 5322 Section 3.6.4: msg-id = "<" id-left "@" id-right ">"
                // Both id-left and id-right must be non-empty.
                // Skip malformed tokens (Postel's law: be conservative in what you send).
                if is_valid_msg_id(bare) {
                    Some(format!("<{bare}>"))
                } else {
                    None
                }
            })
            .collect();
        if !ids.is_empty() {
            try_write_header(&mut raw, "In-Reply-To", &ids.join(" "))?;
        }
    }

    // References (RFC 5322 Section 3.6.4): references = 1*msg-id.
    // Each Vec element is a bare message-id; wrap each in angle brackets.
    if !email.references.is_empty() {
        let refs: Vec<String> = email
            .references
            .iter()
            .filter_map(|id| {
                let sanitized = sanitize_header_value(id.as_str());
                // Strip existing angle brackets before wrapping
                let bare = strip_angle_brackets(&sanitized);
                // RFC 5322 Section 3.6.4: msg-id = "<" id-left "@" id-right ">"
                // Both id-left and id-right must be non-empty.
                if is_valid_msg_id(bare) {
                    Some(format!("<{bare}>"))
                } else {
                    None
                }
            })
            .collect();
        if !refs.is_empty() {
            try_write_header(&mut raw, "References", &refs.join(" "))?;
        }
    }

    // Extra optional fields that are neither trace fields nor resent fields
    // follow the originator / identification block (RFC 5322 Section 3.6.8).
    for (name, value) in &regular_extra_headers {
        try_write_header(&mut raw, name, value)?;
    }

    // --- Body ---

    let has_text = email.body_text.is_some();
    let has_html = email.body_html.is_some();

    // RFC 2387: inline attachments with a Content-ID that are referenced by
    // the HTML body (via `cid:` URLs) must be grouped with the HTML in a
    // `multipart/related` container. Separate them from regular attachments.
    let (inline_atts, regular_atts): (Vec<_>, Vec<_>) = email
        .attachments
        .iter()
        .partition(|a| a.is_inline && a.content_id.is_some());
    // RFC 2046 Section 5.1: any attachment must produce a multipart message.
    // Use the original list length rather than the partitioned inline/regular
    // flags so that inline attachments without an HTML body (no `cid:`
    // reference target) are still emitted — the re-classification inside the
    // `if has_attachments` block handles them as regular attachments.
    let has_attachments = !email.attachments.is_empty();

    // Collect all encapsulated content so that generated boundaries can be
    // verified not to collide with it (RFC 2046 Section 5.1.1: "The boundary
    // delimiter MUST NOT appear within the encapsulated material.").
    let encapsulated_content = {
        let mut buf = Vec::new();
        if let Some(ref text) = email.body_text {
            buf.extend_from_slice(text.as_bytes());
        }
        if let Some(ref html) = email.body_html {
            buf.extend_from_slice(html.as_bytes());
        }
        for att in &email.attachments {
            buf.extend_from_slice(&att.data);
            buf.extend_from_slice(att.filename.as_bytes());
        }
        buf
    };

    // Track all generated boundaries to ensure uniqueness across nesting
    // levels (RFC 2046 Section 5.1.1).
    let mut used_boundaries: Vec<String> = Vec::new();
    let new_boundary = |content: &[u8], used: &mut Vec<String>| -> String {
        loop {
            let b = generate_boundary_not_in(content);
            if !used.contains(&b) {
                used.push(b.clone());
                return b;
            }
        }
    };

    if has_attachments {
        // When inline attachments exist without HTML (no `cid:` reference
        // target), treat them as regular attachments.
        let (inline_atts, regular_atts) = if has_html {
            (inline_atts, regular_atts)
        } else {
            (Vec::new(), email.attachments.iter().collect::<Vec<_>>())
        };
        let has_inline = !inline_atts.is_empty();
        let has_regular = !regular_atts.is_empty();
        // Outer multipart/mixed is needed when there are regular (non-inline)
        // attachments, or when there are no inline attachments (all are
        // regular by default).
        let needs_mixed = has_regular || !has_inline;

        let mixed_boundary = if needs_mixed {
            let b = new_boundary(&encapsulated_content, &mut used_boundaries);
            // Outer multipart/mixed (RFC 2046 Section 5.1.3)
            try_write_header(
                &mut raw,
                "Content-Type",
                &format!("multipart/mixed; boundary=\"{b}\""),
            )?;
            raw.extend_from_slice(b"\r\n");
            write_boundary(&mut raw, &b, false);
            Some(b)
        } else {
            None
        };

        if has_inline {
            // RFC 2387: wrap HTML body + inline attachments in multipart/related.
            let related_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
            // RFC 2387 Section 3.1: the `type` parameter is REQUIRED and
            // specifies the MIME type of the root body part.
            let root_type = if has_text && has_html {
                "multipart/alternative"
            } else {
                "text/html"
            };
            try_write_header(
                &mut raw,
                "Content-Type",
                &format!(
                    "multipart/related; type=\"{root_type}\"; boundary=\"{related_boundary}\""
                ),
            )?;
            raw.extend_from_slice(b"\r\n");
            write_boundary(&mut raw, &related_boundary, false);

            // The root part of multipart/related is the HTML body (or
            // multipart/alternative if both text and HTML are present).
            if has_text && has_html {
                let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
                try_write_header(
                    &mut raw,
                    "Content-Type",
                    &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
                )?;
                raw.extend_from_slice(b"\r\n");

                write_boundary(&mut raw, &alt_boundary, false);
                write_text_part(
                    &mut raw,
                    email.body_text.as_deref().unwrap_or(""),
                    "text/plain",
                )?;
                write_boundary(&mut raw, &alt_boundary, false);
                write_text_part(
                    &mut raw,
                    email.body_html.as_deref().unwrap_or(""),
                    "text/html",
                )?;
                write_boundary(&mut raw, &alt_boundary, true);
            } else {
                write_text_part(
                    &mut raw,
                    email.body_html.as_deref().unwrap_or(""),
                    "text/html",
                )?;
            }

            // Inline attachments inside multipart/related
            for attachment in &inline_atts {
                write_boundary(&mut raw, &related_boundary, false);
                write_attachment_part(&mut raw, attachment)?;
            }

            write_boundary(&mut raw, &related_boundary, true);
        } else {
            // No inline attachments — write body part(s) directly
            if has_text && has_html {
                let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
                try_write_header(
                    &mut raw,
                    "Content-Type",
                    &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
                )?;
                raw.extend_from_slice(b"\r\n");

                write_boundary(&mut raw, &alt_boundary, false);
                write_text_part(
                    &mut raw,
                    email.body_text.as_deref().unwrap_or(""),
                    "text/plain",
                )?;
                write_boundary(&mut raw, &alt_boundary, false);
                write_text_part(
                    &mut raw,
                    email.body_html.as_deref().unwrap_or(""),
                    "text/html",
                )?;
                write_boundary(&mut raw, &alt_boundary, true);
            } else if has_text {
                write_text_part(
                    &mut raw,
                    email.body_text.as_deref().unwrap_or(""),
                    "text/plain",
                )?;
            } else if has_html {
                write_text_part(
                    &mut raw,
                    email.body_html.as_deref().unwrap_or(""),
                    "text/html",
                )?;
            } else {
                write_text_part(&mut raw, "", "text/plain")?;
            }
        }

        // Regular (non-inline) attachment parts in multipart/mixed
        if let Some(ref mixed_b) = mixed_boundary {
            for attachment in &regular_atts {
                write_boundary(&mut raw, mixed_b, false);
                write_attachment_part(&mut raw, attachment)?;
            }
            write_boundary(&mut raw, mixed_b, true);
        }
    } else if has_text && has_html {
        // multipart/alternative (RFC 2046 Section 5.1.4)
        let alt_boundary = new_boundary(&encapsulated_content, &mut used_boundaries);
        try_write_header(
            &mut raw,
            "Content-Type",
            &format!("multipart/alternative; boundary=\"{alt_boundary}\""),
        )?;
        raw.extend_from_slice(b"\r\n");

        write_boundary(&mut raw, &alt_boundary, false);
        write_text_part(
            &mut raw,
            email.body_text.as_deref().unwrap_or(""),
            "text/plain",
        )?;

        write_boundary(&mut raw, &alt_boundary, false);
        write_text_part(
            &mut raw,
            email.body_html.as_deref().unwrap_or(""),
            "text/html",
        )?;

        write_boundary(&mut raw, &alt_boundary, true);
    } else if has_html {
        // Single text/html — delegate to write_text_part which handles
        // quoted-printable fallback for long lines (RFC 2045 Section 2.8).
        write_text_part(
            &mut raw,
            email.body_html.as_deref().unwrap_or(""),
            "text/html",
        )?;
    } else {
        // Single text/plain or empty body — delegate to write_text_part which
        // handles quoted-printable fallback for long lines (RFC 2045 Section 2.8).
        write_text_part(
            &mut raw,
            email.body_text.as_deref().unwrap_or(""),
            "text/plain",
        )?;
    }

    // Collect envelope recipients (to + cc + bcc) for SMTP RCPT TO
    let mut envelope_recipients: Vec<String> = email.to.iter().map(|a| a.email.clone()).collect();
    envelope_recipients.extend(email.cc.iter().map(|a| a.email.clone()));
    envelope_recipients.extend(email.bcc.iter().map(|a| a.email.clone()));

    Ok(BuiltMessage {
        raw,
        envelope_recipients,
        message_id,
    })
}