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 ®ular_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 ®ular_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}