Skip to main content

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}