email_message_wire/rfc822.rs
1mod attachment;
2mod content_type;
3mod encoded_word;
4mod header;
5mod mime_parse;
6mod mime_render;
7mod shared;
8mod transfer_encoding;
9
10use std::str::FromStr;
11
12use email_message::{
13 Address, AddressList, ContentTransferEncoding, Header, Mailbox, Message, MessageId,
14 MessageValidationError,
15};
16use time::OffsetDateTime;
17use time::format_description::well_known::Rfc2822;
18
19pub use encoded_word::decode_rfc2047_phrase;
20use encoded_word::{
21 decode_rfc2047_words, encode_rfc2047_unstructured, escape_encoded_words_inside_quoted_strings,
22};
23use header::{
24 is_structured_header, parse_header_lines_bytes, push_header_line, render_address_list_header,
25 render_mailbox_header, split_headers_and_body_bytes,
26};
27use mime_render::build_render_payload;
28pub use shared::{MAX_INPUT_BYTES, MAX_MULTIPART_DEPTH, MAX_MULTIPART_PARTS};
29
30/// Errors returned while parsing RFC 822/MIME bytes.
31#[derive(Debug, thiserror::Error)]
32#[non_exhaustive]
33pub enum MessageParseError {
34 /// A header line is not valid UTF-8.
35 #[error("input is not valid UTF-8")]
36 InvalidUtf8,
37 /// A header line violates the supported RFC 5322 syntax or byte rules.
38 #[error("invalid header line `{line}`")]
39 #[non_exhaustive]
40 InvalidHeaderLine {
41 /// Rejected line or validation details.
42 line: String,
43 },
44 /// A single-mailbox header could not be parsed.
45 #[error("failed to parse mailbox from `{header}` header")]
46 #[non_exhaustive]
47 MailboxHeaderParse {
48 /// Header field containing the invalid mailbox.
49 header: &'static str,
50 },
51 /// An address-list header could not be parsed.
52 #[error("failed to parse address list from `{header}` header")]
53 #[non_exhaustive]
54 AddressHeaderParse {
55 /// Header field containing the invalid address list.
56 header: &'static str,
57 },
58 /// The `Date` header is not a valid RFC 2822 date-time.
59 #[error("failed to parse Date header as RFC 2822 datetime")]
60 #[non_exhaustive]
61 Date {
62 /// Underlying date-time parse error.
63 #[source]
64 source: time::error::Parse,
65 },
66 /// The `Message-ID` header is invalid.
67 #[error("failed to parse Message-ID header")]
68 #[non_exhaustive]
69 MessageId {
70 /// Underlying message-id parse error.
71 #[source]
72 source: email_message::MessageIdParseError,
73 },
74 /// MIME structure, metadata, or transfer-encoded content is invalid.
75 #[error("failed to parse MIME body: {details}")]
76 #[non_exhaustive]
77 MimeBodyParse {
78 /// Description of the invalid MIME input.
79 details: String,
80 },
81}
82
83impl PartialEq for MessageParseError {
84 /// Pragmatic equality: variants compare by tag, ignoring the
85 /// boxed `source` chains on `Date` and `MessageId`. Sufficient
86 /// for tests and avoids forcing `Eq` on third-party error types.
87 fn eq(&self, other: &Self) -> bool {
88 match (self, other) {
89 (Self::InvalidUtf8, Self::InvalidUtf8)
90 | (Self::Date { .. }, Self::Date { .. })
91 | (Self::MessageId { .. }, Self::MessageId { .. }) => true,
92 (Self::InvalidHeaderLine { line: a }, Self::InvalidHeaderLine { line: b })
93 | (Self::MimeBodyParse { details: a }, Self::MimeBodyParse { details: b }) => a == b,
94 (Self::MailboxHeaderParse { header: a }, Self::MailboxHeaderParse { header: b })
95 | (Self::AddressHeaderParse { header: a }, Self::AddressHeaderParse { header: b }) => {
96 a == b
97 }
98 _ => false,
99 }
100 }
101}
102
103impl Eq for MessageParseError {}
104
105/// Errors returned while rendering an RFC 822/MIME message.
106#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
107#[non_exhaustive]
108pub enum MessageRenderError {
109 /// A header value contains a raw carriage return or line feed.
110 #[error("header `{name}` contains raw newline characters")]
111 #[non_exhaustive]
112 HeaderContainsRawNewline {
113 /// Invalid header name.
114 name: String,
115 },
116 /// A header value contains a forbidden control character.
117 #[error("header `{name}` contains invalid control characters")]
118 #[non_exhaustive]
119 HeaderContainsControlCharacter {
120 /// Invalid header name.
121 name: String,
122 },
123 /// A header remains non-ASCII after applicable encoding.
124 #[error("header `{name}` contains non-ASCII characters")]
125 #[non_exhaustive]
126 HeaderContainsNonAscii {
127 /// Invalid header name.
128 name: String,
129 },
130 /// A header name violates RFC 5322 field-name syntax.
131 #[error("header name `{name}` is invalid")]
132 #[non_exhaustive]
133 InvalidHeaderName {
134 /// Invalid header name.
135 name: String,
136 },
137 /// A header cannot be folded below the RFC 5322 hard line limit.
138 #[error("header `{name}` exceeds RFC 5322 hard line length limit")]
139 #[non_exhaustive]
140 HeaderLineTooLong {
141 /// Overlong header name.
142 name: String,
143 },
144 /// The message date could not be formatted as RFC 2822.
145 #[error("failed to format Date header as RFC 2822 datetime")]
146 DateFormat,
147 /// A MIME boundary is empty.
148 #[error("MIME boundary cannot be empty")]
149 EmptyMimeBoundary,
150 /// A MIME boundary contains bytes forbidden by the supported grammar.
151 #[error("MIME boundary contains forbidden characters")]
152 InvalidMimeBoundary,
153 /// The content-type boundary parameter differs from the part boundary.
154 #[error("multipart boundary parameter does not match part boundary")]
155 MismatchedMimeBoundary,
156 /// A multipart node contains no child parts.
157 #[error("multipart parts cannot be empty")]
158 EmptyMultipartParts,
159 /// A MIME tree exceeds [`MAX_MULTIPART_DEPTH`].
160 #[error("multipart nesting exceeds maximum depth of {MAX_MULTIPART_DEPTH}")]
161 MimeNestingTooDeep,
162 /// A multipart node's content type is not `multipart/*`.
163 #[error("multipart part must use a multipart content type")]
164 InvalidMultipartContentType,
165 /// An unresolved attachment reference cannot be rendered.
166 #[error("attachment body variant is not supported")]
167 UnsupportedAttachmentBody,
168 /// An attachment content id is not a valid message-id value.
169 #[error("attachment content-id is invalid")]
170 InvalidContentId,
171 /// The message contains a body variant unsupported by this renderer.
172 #[error("message body variant is not supported")]
173 UnsupportedBody,
174 /// The message failed baseline outbound validation.
175 #[error(transparent)]
176 MessageValidation(#[from] MessageValidationError),
177}
178
179/// Render-time options for [`render_rfc822_with`].
180///
181/// The struct is `#[non_exhaustive]`; future fields will be additive.
182/// Construct via [`Self::new`] or [`Self::default`] and chain
183/// `with_*` setters.
184#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
185#[non_exhaustive]
186pub struct RenderOptions {
187 /// When `true`, the rendered message includes a `Bcc:` header line
188 /// listing the message's BCC recipients. Defaults to `false`.
189 ///
190 /// Most SMTP relays strip `Bcc:` on submission anyway; rendering
191 /// the field is occasionally useful for archival, `.eml` fixtures,
192 /// or clients that consume the rendered bytes outside the SMTP
193 /// path.
194 pub include_bcc: bool,
195 /// Optional soft-fold target for header lines, in characters.
196 ///
197 /// `None` (the default) emits header lines at the RFC 5322 §2.1.1
198 /// hard limit of 998 characters with no soft folding, long values
199 /// flow on a single physical line. `Some(n)` instructs the renderer
200 /// to fold longer lines at `n` characters via the standard
201 /// folding-whitespace mechanism (CRLF + leading SP/HTAB), targeting
202 /// the SHOULD ≤ 78 recommendation when `n == 78`.
203 ///
204 /// The default is `None` because correct soft folding requires
205 /// per-header-grammar awareness (encoded-word boundaries,
206 /// address-list comma discipline, structured-header whitespace
207 /// rules) that the simple folding helper cannot guarantee in every
208 /// case. Callers who want SHOULD-compliant output for archival or
209 /// for strict legacy MTAs can opt in via `with_soft_fold(78)`; the
210 /// renderer still respects the 998 hard limit regardless.
211 pub soft_fold_at: Option<usize>,
212}
213
214impl RenderOptions {
215 /// Creates options with Bcc output and soft folding disabled.
216 #[must_use]
217 pub const fn new() -> Self {
218 Self {
219 include_bcc: false,
220 soft_fold_at: None,
221 }
222 }
223
224 /// Chooses whether to emit the `Bcc` header.
225 #[must_use]
226 pub const fn with_include_bcc(mut self, value: bool) -> Self {
227 self.include_bcc = value;
228 self
229 }
230
231 /// Set the soft-fold target. Pass `78` for the RFC 5322 §2.1.1
232 /// SHOULD-compliant recommendation; pass any other positive integer
233 /// up to `997` for a custom target.
234 #[must_use]
235 pub const fn with_soft_fold(mut self, soft_fold_at: usize) -> Self {
236 self.soft_fold_at = Some(soft_fold_at);
237 self
238 }
239
240 /// Disable soft folding. Long header values flow on one physical
241 /// line up to the 998-character hard limit.
242 #[must_use]
243 pub const fn without_soft_fold(mut self) -> Self {
244 self.soft_fold_at = None;
245 self
246 }
247}
248
249/// Parse RFC822/MIME bytes into a structured [`Message`].
250///
251/// # Decoding behavior
252///
253/// - **Body charset.** Bodies declared `utf-8`, `us-ascii`, `iso-8859-1`,
254/// or `latin1` are decoded faithfully. Bodies in other charsets, or
255/// bodies declared `utf-8` with invalid UTF-8 byte sequences, are
256/// passed through `String::from_utf8_lossy`, invalid bytes become
257/// `U+FFFD`. The parser does not error on undecodable bytes; users
258/// needing strict decode semantics should pre-validate.
259/// - **Encoded words.** RFC 2047 encoded words (`=?charset?Q?…?=` /
260/// `=?charset?B?…?=`) are decoded for the same charset allowlist.
261/// Encoded words in other charsets (e.g. `windows-1252`, `gbk`,
262/// `shift_jis`) pass through as the raw `=?…?=` literal.
263/// - **Duplicate headers.** Multiple `To:`, `Cc:`, `Bcc:`, or `Reply-To:`
264/// header lines are merged into a single recipient list. RFC 5322 §3.6
265/// forbids duplicates, but real MTAs occasionally emit them; the
266/// parser is liberal in what it accepts. Outbound rendering emits one
267/// line per category.
268/// - **RFC 6532 (SMTPUTF8).** Header *lines* must be ASCII-only. Senders
269/// that put UTF-8 directly in header bodies (without RFC 2047 encoding)
270/// are rejected with [`MessageParseError::InvalidHeaderLine`]. Most
271/// senders RFC 2047-encode for compat; this rarely surfaces.
272///
273/// # Returned message
274///
275/// The returned [`Message`] has not been promoted through outbound
276/// validation. Wrapping it via [`email_message::OutboundMessage::new`]
277/// may reject inbound-shaped messages that lack a `From:` header or
278/// have no recipients, both legitimate states for an inbound parse.
279///
280/// # Round-trip caveats
281///
282/// `parse_rfc822` is a typed-model deserializer, not a byte-faithful
283/// re-emitter. A `parse → render_rfc822` round-trip is **not** guaranteed
284/// to produce identical bytes:
285///
286/// - **Header order.** Headers are emitted in a fixed canonical order
287/// (`From`, `Sender`, `To`, `Cc`, `Bcc`, `Reply-To`, `Subject`, `Date`,
288/// `Message-ID`, generic headers, MIME headers). Trace metadata such
289/// as `Received:` is preserved as a generic header but appears below
290/// the typed fields rather than at its original parse position.
291/// - **Generic-header decoding asymmetry.** RFC 2047 encoded-words are
292/// decoded for `Subject` and the address headers (`From`, `Sender`,
293/// `To`, `Cc`, `Bcc`, `Reply-To`). For arbitrary other headers, values
294/// are preserved literally, a header value emitted as
295/// `X-Note: =?utf-8?B?w6Fy?=` round-trips as the literal bytes
296/// `=?utf-8?B?w6Fy?=`, *not* the decoded text `ár`. Auto-decoding
297/// every unstructured header would be a security regression because
298/// opaque-bytes headers (`X-Auth-Token`, `DKIM-Signature`,
299/// `Authentication-Results`, `ARC-*`) carry data that must not be
300/// silently rewritten. Callers who *know* a header is unstructured-text
301/// shaped can opt into decoding via [`decode_rfc2047_phrase`].
302///
303/// # Resource bounds
304///
305/// The parser is best-effort and bounded against adversarial input:
306///
307/// - **Input length.** Inputs larger than [`MAX_INPUT_BYTES`] (16 MiB)
308/// are rejected outright with [`MessageParseError::MimeBodyParse`].
309/// - **Multipart depth.** Nested `multipart/*` parts are limited to
310/// [`MAX_MULTIPART_DEPTH`] (100 levels). Deeper inputs would otherwise
311/// stack-overflow on the mutual recursion between the multipart body
312/// parser and the part parser.
313/// - **Multipart fan-out.** A single multipart body cannot contain more
314/// than [`MAX_MULTIPART_PARTS`] (1024) sibling parts.
315///
316/// These caps cover the recursive *parser* surface. The renderer
317/// (`render_rfc822` and `render_rfc822_with`) enforces the symmetric
318/// [`MAX_MULTIPART_DEPTH`] cap on outbound trees, including up to two
319/// frames of attachment-wrapping added by the renderer itself when
320/// inline and/or regular attachments are present (one
321/// `multipart/related` frame for inline parts, one `multipart/mixed`
322/// frame for regular parts). It returns
323/// [`MessageRenderError::MimeNestingTooDeep`] when a `Body::Mime` value
324/// plus those wrap frames exceeds the cap. A `Body::Mime` value at
325/// exactly [`MAX_MULTIPART_DEPTH`] therefore renders cleanly when no
326/// attachments are present but errors when wrapped.
327///
328/// The kernel does **not** depth-cap `serde::Deserialize<Body>` /
329/// `Deserialize<MimePart>` because the recursive
330/// `MimePart::Multipart { parts: Vec<Self> }` shape is the data model,
331/// not a parser artifact. Callers who deserialize untrusted JSON into
332/// [`email_message::Body`] are responsible for pre-bounding the input
333/// themselves (e.g. via `serde_json::de::Deserializer::disable_recursion_limit`
334/// left at its 128-level default, or a separate length cap). The render
335/// path enforces its own cap regardless, so an unbounded deserialize
336/// followed by `render_rfc822` errors cleanly rather than overflowing
337/// the stack.
338///
339/// # Errors
340///
341/// Returns [`MessageParseError`] when headers, mailbox fields, dates,
342/// message ids, MIME metadata, or transfer-encoded bodies are malformed.
343#[allow(clippy::too_many_lines)]
344pub fn parse_rfc822(input: &[u8]) -> Result<Message, MessageParseError> {
345 if input.len() > MAX_INPUT_BYTES {
346 return Err(MessageParseError::MimeBodyParse {
347 details: format!(
348 "input is {} bytes, exceeding maximum of {MAX_INPUT_BYTES}",
349 input.len()
350 ),
351 });
352 }
353
354 let (raw_headers, raw_body) = split_headers_and_body_bytes(input);
355 let parsed_headers = parse_header_lines_bytes(raw_headers)?;
356
357 let mut from: Option<Mailbox> = None;
358 let mut sender: Option<Mailbox> = None;
359 let mut to: Vec<Address> = Vec::new();
360 let mut cc: Vec<Address> = Vec::new();
361 let mut bcc: Vec<Address> = Vec::new();
362 let mut reply_to: Vec<Address> = Vec::new();
363 let mut subject: Option<String> = None;
364 let mut date: Option<OffsetDateTime> = None;
365 let mut message_id: Option<MessageId> = None;
366 let mut root_content_type: Option<String> = None;
367 let mut root_content_transfer_encoding: Option<ContentTransferEncoding> = None;
368 let mut headers = Vec::new();
369
370 for (header_name, header_value) in parsed_headers {
371 let header_name_ref = header_name.as_str();
372 let header_value_ref = header_value.as_str();
373 let decoded_header_value = decode_rfc2047_words(header_value_ref);
374
375 // Address-typed headers route the *raw* header value to the
376 // address parser, after escaping encoded-words inside any
377 // quoted-string regions (see
378 // `escape_encoded_words_inside_quoted_strings`). The kernel's
379 // own `decode_rfc2047_words` pass would unconditionally decode
380 // them and the upstream `mail_parser` does the same; the
381 // pre-escape is the only place where the RFC 2047 §5(3) rule
382 // is enforced.
383 let address_value = escape_encoded_words_inside_quoted_strings(header_value_ref);
384 if header_name_ref.eq_ignore_ascii_case("from") {
385 from = Some(
386 address_value
387 .parse::<Mailbox>()
388 .map_err(|_| MessageParseError::MailboxHeaderParse { header: "From" })?,
389 );
390 continue;
391 }
392
393 if header_name_ref.eq_ignore_ascii_case("sender") {
394 sender = Some(
395 address_value
396 .parse::<Mailbox>()
397 .map_err(|_| MessageParseError::MailboxHeaderParse { header: "Sender" })?,
398 );
399 continue;
400 }
401
402 if header_name_ref.eq_ignore_ascii_case("to") {
403 let mut parsed = AddressList::from_str(&address_value)
404 .map_err(|_| MessageParseError::AddressHeaderParse { header: "To" })?
405 .into_vec();
406 to.append(&mut parsed);
407 continue;
408 }
409
410 if header_name_ref.eq_ignore_ascii_case("cc") {
411 let mut parsed = AddressList::from_str(&address_value)
412 .map_err(|_| MessageParseError::AddressHeaderParse { header: "Cc" })?
413 .into_vec();
414 cc.append(&mut parsed);
415 continue;
416 }
417
418 if header_name_ref.eq_ignore_ascii_case("bcc") {
419 let mut parsed = AddressList::from_str(&address_value)
420 .map_err(|_| MessageParseError::AddressHeaderParse { header: "Bcc" })?
421 .into_vec();
422 bcc.append(&mut parsed);
423 continue;
424 }
425
426 if header_name_ref.eq_ignore_ascii_case("reply-to") {
427 let mut parsed = AddressList::from_str(&address_value)
428 .map_err(|_| MessageParseError::AddressHeaderParse { header: "Reply-To" })?
429 .into_vec();
430 reply_to.append(&mut parsed);
431 continue;
432 }
433
434 if header_name_ref.eq_ignore_ascii_case("subject") {
435 subject = Some(decoded_header_value.into_owned());
436 continue;
437 }
438
439 if header_name_ref.eq_ignore_ascii_case("date") {
440 date = Some(
441 OffsetDateTime::parse(header_value_ref.trim(), &Rfc2822)
442 .map_err(|source| MessageParseError::Date { source })?,
443 );
444 continue;
445 }
446
447 if header_name_ref.eq_ignore_ascii_case("message-id") {
448 message_id = Some(
449 MessageId::try_from(header_value_ref.trim())
450 .map_err(|source| MessageParseError::MessageId { source })?,
451 );
452 continue;
453 }
454
455 if header_name_ref.eq_ignore_ascii_case("content-type") {
456 root_content_type = Some(header_value);
457 continue;
458 }
459
460 if header_name_ref.eq_ignore_ascii_case("content-transfer-encoding") {
461 root_content_transfer_encoding = Some(
462 ContentTransferEncoding::from_str(header_value_ref).map_err(|_| {
463 MessageParseError::MimeBodyParse {
464 details: format!(
465 "invalid top-level content-transfer-encoding `{header_value_ref}`"
466 ),
467 }
468 })?,
469 );
470 continue;
471 }
472
473 headers.push(Header::new(header_name, header_value).map_err(|error| {
474 MessageParseError::InvalidHeaderLine {
475 line: error.to_string(),
476 }
477 })?);
478 }
479
480 let body = mime_parse::parse_body(
481 raw_body,
482 root_content_type.as_deref(),
483 root_content_transfer_encoding,
484 )?;
485
486 let mut builder = Message::builder(body)
487 .to(to)
488 .cc(cc)
489 .bcc(bcc)
490 .reply_to(reply_to)
491 .headers(headers)
492 .attachments(Vec::new());
493
494 if let Some(from) = from {
495 builder = builder.from_mailbox(from);
496 }
497
498 if let Some(sender) = sender {
499 builder = builder.sender(sender);
500 }
501
502 if let Some(subject) = subject {
503 builder = builder.subject(subject);
504 }
505
506 if let Some(date) = date {
507 builder = builder.date(date);
508 }
509
510 if let Some(message_id) = message_id {
511 builder = builder.message_id(message_id);
512 }
513
514 Ok(builder.build_unchecked())
515}
516
517/// Render a structured [`Message`] as RFC822/MIME bytes.
518///
519/// # Encoding choices
520///
521/// Non-ASCII [`Body::Text`](email_message::Body) and `Body::Html` values are
522/// always rendered with `Content-Transfer-Encoding: base64`. ASCII text bodies
523/// whose physical lines would exceed RFC 5322's 998-octet hard limit are
524/// rendered with `Content-Transfer-Encoding: quoted-printable`. A message
525/// parsed from quoted-printable bytes through [`parse_rfc822`] and rendered
526/// back through this function will therefore round-trip with a different
527/// `Content-Transfer-Encoding`. Callers that need quoted-printable for
528/// near-ASCII bodies can construct a [`MimePart::Leaf`](email_message::MimePart)
529/// with an explicit `content_transfer_encoding` and use
530/// [`Body::Mime`](email_message::Body).
531///
532/// # Errors
533///
534/// Returns [`MessageRenderError`] when headers or MIME parts cannot be rendered
535/// according to this crate's RFC822 constraints.
536pub fn render_rfc822(message: &Message) -> Result<Vec<u8>, MessageRenderError> {
537 render_rfc822_with(message, &RenderOptions::default())
538}
539
540/// Render a structured [`Message`] as RFC822/MIME bytes with custom options.
541///
542/// See [`render_rfc822`] for the encoding-choice notes; the same trade-offs
543/// apply.
544///
545/// # Errors
546///
547/// Returns [`MessageRenderError`] when headers or MIME parts cannot be rendered
548/// according to this crate's RFC822 constraints.
549#[allow(clippy::too_many_lines)]
550pub fn render_rfc822_with(
551 message: &Message,
552 options: &RenderOptions,
553) -> Result<Vec<u8>, MessageRenderError> {
554 message.validate_basic()?;
555
556 let mut out = Vec::new();
557
558 if let Some(from) = message.from_mailbox() {
559 push_header_line(
560 &mut out,
561 "From",
562 &render_mailbox_header(from),
563 options.soft_fold_at,
564 )?;
565 }
566
567 if let Some(sender) = message.sender() {
568 push_header_line(
569 &mut out,
570 "Sender",
571 &render_mailbox_header(sender),
572 options.soft_fold_at,
573 )?;
574 }
575
576 if !message.to().is_empty() {
577 push_header_line(
578 &mut out,
579 "To",
580 &render_address_list_header(message.to()),
581 options.soft_fold_at,
582 )?;
583 }
584
585 if !message.cc().is_empty() {
586 push_header_line(
587 &mut out,
588 "Cc",
589 &render_address_list_header(message.cc()),
590 options.soft_fold_at,
591 )?;
592 }
593
594 if options.include_bcc && !message.bcc().is_empty() {
595 push_header_line(
596 &mut out,
597 "Bcc",
598 &render_address_list_header(message.bcc()),
599 options.soft_fold_at,
600 )?;
601 }
602
603 if !message.reply_to().is_empty() {
604 push_header_line(
605 &mut out,
606 "Reply-To",
607 &render_address_list_header(message.reply_to()),
608 options.soft_fold_at,
609 )?;
610 }
611
612 if let Some(subject) = message.subject() {
613 push_header_line(
614 &mut out,
615 "Subject",
616 &encode_rfc2047_unstructured(subject),
617 options.soft_fold_at,
618 )?;
619 }
620
621 if let Some(date) = message.date() {
622 let formatted = date
623 .format(&Rfc2822)
624 .map_err(|_| MessageRenderError::DateFormat)?;
625 push_header_line(&mut out, "Date", &formatted, options.soft_fold_at)?;
626 }
627
628 if let Some(message_id) = message.message_id() {
629 push_header_line(
630 &mut out,
631 "Message-ID",
632 message_id.as_str(),
633 options.soft_fold_at,
634 )?;
635 }
636
637 let (mime_headers, body_out, is_mime) = build_render_payload(message, options.soft_fold_at)?;
638
639 for header in message.headers() {
640 if is_mime
641 && (header.name().eq_ignore_ascii_case("content-type")
642 || header
643 .name()
644 .eq_ignore_ascii_case("content-transfer-encoding")
645 || header.name().eq_ignore_ascii_case("mime-version"))
646 {
647 continue;
648 }
649 // RFC 2047 only applies to *unstructured* fields. Structured
650 // headers (Message-ID, In-Reply-To, References, List-*, Received,
651 // and the standard structured fields) carry their own grammar and
652 // would be corrupted by encoded-word substitution. Generic
653 // headers default to unstructured; a small allowlist below
654 // bypasses the encoder for the structured ones.
655 let value_owned;
656 let value: &str = if header.value().is_ascii() || is_structured_header(header.name()) {
657 header.value()
658 } else {
659 value_owned = encode_rfc2047_unstructured(header.value());
660 &value_owned
661 };
662 push_header_line(&mut out, header.name(), value, options.soft_fold_at)?;
663 }
664
665 if is_mime {
666 push_header_line(&mut out, "MIME-Version", "1.0", options.soft_fold_at)?;
667 for (name, value) in mime_headers {
668 push_header_line(&mut out, &name, &value, options.soft_fold_at)?;
669 }
670 }
671
672 out.extend_from_slice(b"\r\n");
673 out.extend_from_slice(&body_out);
674
675 Ok(out)
676}
677
678#[cfg(test)]
679mod tests {
680 use email_message::{Body, Message, MessageId};
681 use time::OffsetDateTime;
682 use time::format_description::well_known::Rfc2822;
683
684 use super::{parse_rfc822, render_rfc822};
685
686 #[test]
687 fn parse_rfc822_extracts_core_headers_and_body() {
688 let input = concat!(
689 "From: Mary Smith <mary@x.test>\r\n",
690 "To: jdoe@one.test\r\n",
691 "Subject: Test\r\n",
692 "Date: Fri, 06 Mar 2026 12:00:00 +0000\r\n",
693 "Message-ID: <test@example.com>\r\n",
694 "X-Custom: demo\r\n",
695 "\r\n",
696 "hello"
697 );
698
699 let message = parse_rfc822(input.as_bytes()).expect("message should parse");
700 assert_eq!(message.subject(), Some("Test"));
701 assert_eq!(message.to().len(), 1);
702 assert_eq!(
703 message.date(),
704 Some(
705 &OffsetDateTime::parse("Fri, 06 Mar 2026 12:00:00 +0000", &Rfc2822)
706 .expect("date should parse")
707 )
708 );
709 assert_eq!(
710 message.message_id(),
711 Some(
712 &"<test@example.com>"
713 .parse::<MessageId>()
714 .expect("message id should parse")
715 )
716 );
717 assert_eq!(message.body(), &Body::Text("hello".to_owned()));
718 }
719
720 #[test]
721 fn render_rfc822_writes_expected_lines() {
722 let message = Message::builder(Body::Text("hello".to_owned()))
723 .from_mailbox("Mary Smith <mary@x.test>".parse().expect("valid mailbox"))
724 .to(vec![email_message::Address::Mailbox(
725 "jdoe@one.test".parse().expect("valid mailbox"),
726 )])
727 .subject("Test")
728 .build()
729 .expect("message should validate");
730
731 let rendered = render_rfc822(&message).expect("render should succeed");
732 let text = String::from_utf8(rendered).expect("rendered text should be utf8");
733
734 assert!(text.contains("From: \"Mary Smith\" <mary@x.test>\r\n"));
735 assert!(text.contains("To: jdoe@one.test\r\n"));
736 assert!(text.contains("Subject: Test\r\n"));
737 assert!(text.ends_with("\r\n\r\nhello"));
738 }
739}