Skip to main content

email_message/
message.rs

1//! Provider-independent message bodies, attachments, headers, and validation.
2//!
3//! [`MessageBuilder`] constructs both inbound-shaped [`Message`] values and
4//! validated [`OutboundMessage`] values. Wire-specific MIME encoding remains in
5//! `email-message-wire`.
6
7use crate::mime_types::ContentType;
8#[cfg(feature = "mime")]
9use crate::mime_types::MimePart;
10use crate::{Address, EmailAddress, Mailbox, MessageId};
11use time::OffsetDateTime;
12
13/// SMTP envelope addresses.
14#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
16#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
17#[derive(Clone, Debug, PartialEq, Eq, Hash)]
18pub struct Envelope {
19    #[cfg_attr(
20        feature = "serde",
21        serde(default, skip_serializing_if = "Option::is_none")
22    )]
23    mail_from: Option<EmailAddress>,
24    #[cfg_attr(
25        feature = "serde",
26        serde(default, skip_serializing_if = "Vec::is_empty")
27    )]
28    rcpt_to: Vec<EmailAddress>,
29}
30
31impl Envelope {
32    /// Creates an envelope from an optional sender and recipient addresses.
33    #[must_use]
34    pub const fn new(mail_from: Option<EmailAddress>, rcpt_to: Vec<EmailAddress>) -> Self {
35        Self { mail_from, rcpt_to }
36    }
37
38    /// Returns the SMTP `MAIL FROM` address, if present.
39    #[must_use]
40    pub const fn mail_from(&self) -> Option<&EmailAddress> {
41        self.mail_from.as_ref()
42    }
43
44    /// Returns the SMTP `RCPT TO` addresses.
45    #[must_use]
46    pub fn rcpt_to(&self) -> &[EmailAddress] {
47        self.rcpt_to.as_slice()
48    }
49}
50
51/// A single message header line.
52#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
53#[derive(Clone, Debug, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct Header {
56    name: String,
57    value: String,
58}
59
60/// Errors returned when constructing a [`Header`].
61#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
62#[non_exhaustive]
63pub enum HeaderValidationError {
64    /// The header name is empty.
65    #[error("header name cannot be empty")]
66    EmptyName,
67    /// The header name contains a byte outside the RFC 5322 `ftext` range.
68    #[error("header name `{name}` is invalid")]
69    InvalidName {
70        /// Invalid header name.
71        name: String,
72    },
73    /// The header value contains a raw carriage return or line feed.
74    #[error("header `{name}` contains raw newline characters")]
75    ValueContainsRawNewline {
76        /// Name of the invalid header.
77        name: String,
78    },
79    /// The header value contains a forbidden control character.
80    #[error("header `{name}` contains invalid control characters")]
81    ValueContainsControlCharacter {
82        /// Name of the invalid header.
83        name: String,
84    },
85}
86
87impl Header {
88    /// Returns the header field name.
89    #[must_use]
90    pub fn name(&self) -> &str {
91        &self.name
92    }
93
94    /// Returns the header field value.
95    #[must_use]
96    pub fn value(&self) -> &str {
97        &self.value
98    }
99
100    /// Constructs a header after validating name and value.
101    ///
102    /// # Name validation
103    ///
104    /// The name must be non-empty and use only the RFC 5322 §2.2 `ftext`
105    /// byte range (`0x21..=0x39 | 0x3B..=0x7E`). That is the literal
106    /// grammar definition: it admits punctuation such as `@`, `(`, `)`,
107    /// `,`, `<`, `>`, `[`, `]`, `?`, `=`, `\`, `"`. Conventional header
108    /// names use the narrower RFC 7230 §3.2.6 `token` shape (alphanumerics
109    /// plus a small punctuation set). Real MTAs and provider HTTP-header
110    /// maps reject the looser superset; if you produce non-token names
111    /// here the message will still pass kernel validation but will be
112    /// dropped or routed to spam by most receivers. Callers needing the
113    /// `token` shape should validate themselves before calling this
114    /// constructor.
115    ///
116    /// # Errors
117    ///
118    /// Returns [`HeaderValidationError`] when the name uses bytes outside
119    /// the RFC 5322 set or the value contains raw newlines or
120    /// non-tab control characters.
121    pub fn new(
122        name: impl Into<String>,
123        value: impl Into<String>,
124    ) -> Result<Self, HeaderValidationError> {
125        let name = name.into();
126        let value = value.into();
127        validate_header(&name, &value)?;
128        Ok(Self { name, value })
129    }
130}
131
132fn validate_header(name: &str, value: &str) -> Result<(), HeaderValidationError> {
133    if name.is_empty() {
134        return Err(HeaderValidationError::EmptyName);
135    }
136    if !name.bytes().all(is_header_name_byte) {
137        return Err(HeaderValidationError::InvalidName {
138            name: name.to_owned(),
139        });
140    }
141    if value.contains(['\r', '\n']) {
142        return Err(HeaderValidationError::ValueContainsRawNewline {
143            name: name.to_owned(),
144        });
145    }
146    if value
147        .bytes()
148        .any(|byte| byte.is_ascii_control() && byte != b'\t')
149    {
150        return Err(HeaderValidationError::ValueContainsControlCharacter {
151            name: name.to_owned(),
152        });
153    }
154    Ok(())
155}
156
157const fn is_header_name_byte(byte: u8) -> bool {
158    matches!(byte, b'!'..=b'9' | b';'..=b'~')
159}
160
161#[cfg(feature = "serde")]
162impl serde::Serialize for Header {
163    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164    where
165        S: serde::Serializer,
166    {
167        use serde::ser::SerializeStruct;
168
169        let mut value = serializer.serialize_struct("Header", 2)?;
170        value.serialize_field("name", self.name())?;
171        value.serialize_field("value", self.value())?;
172        value.end()
173    }
174}
175
176#[cfg(feature = "serde")]
177impl<'de> serde::Deserialize<'de> for Header {
178    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179    where
180        D: serde::Deserializer<'de>,
181    {
182        #[derive(serde::Deserialize)]
183        struct RawHeader {
184            name: String,
185            value: String,
186        }
187
188        let raw = RawHeader::deserialize(deserializer)?;
189        Self::new(raw.name, raw.value).map_err(serde::de::Error::custom)
190    }
191}
192
193#[cfg(feature = "arbitrary")]
194impl<'a> arbitrary::Arbitrary<'a> for Header {
195    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
196        let suffix = u32::arbitrary(u)?;
197        let value = u32::arbitrary(u)?;
198        Self::new(format!("X-Arbitrary-{suffix}"), value.to_string())
199            .map_err(|_| arbitrary::Error::IncorrectFormat)
200    }
201}
202
203/// An unresolved external attachment body reference.
204///
205/// The value is opaque and interpreted entirely by the configured resolver. It
206/// may be a URI, a plain key, or a provider identifier. Wire renderers reject
207/// references until a preparation layer replaces them with bytes.
208#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
210#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
211#[derive(Clone, Debug, PartialEq, Eq, Hash)]
212#[non_exhaustive]
213pub struct AttachmentReference {
214    reference: String,
215}
216
217impl AttachmentReference {
218    /// Creates a reference from an application-defined opaque value.
219    #[must_use]
220    pub fn new(reference: impl Into<String>) -> Self {
221        Self {
222            reference: reference.into(),
223        }
224    }
225
226    /// Returns the resolver-interpreted string reference.
227    #[must_use]
228    pub fn as_str(&self) -> &str {
229        self.reference.as_str()
230    }
231}
232
233impl From<String> for AttachmentReference {
234    fn from(reference: String) -> Self {
235        Self { reference }
236    }
237}
238
239impl From<&str> for AttachmentReference {
240    fn from(reference: &str) -> Self {
241        Self::from(reference.to_owned())
242    }
243}
244
245impl AsRef<str> for AttachmentReference {
246    fn as_ref(&self) -> &str {
247        self.as_str()
248    }
249}
250
251/// Inline bytes or an unresolved external attachment reference.
252#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
253#[derive(Clone, Debug, PartialEq, Eq)]
254#[non_exhaustive]
255pub enum AttachmentBody {
256    /// Attachment bytes available for immediate rendering.
257    Bytes(Vec<u8>),
258    /// External content that must be resolved before wire rendering.
259    Reference(AttachmentReference),
260}
261
262#[cfg(feature = "serde")]
263impl serde::Serialize for AttachmentBody {
264    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
265    where
266        S: serde::Serializer,
267    {
268        use base64::Engine as _;
269        use serde::ser::SerializeStruct as _;
270
271        match self {
272            Self::Bytes(bytes) => {
273                let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
274                let mut value = serializer.serialize_struct("AttachmentBody", 2)?;
275                value.serialize_field("type", "bytes")?;
276                value.serialize_field("bytes", &encoded)?;
277                value.end()
278            }
279            Self::Reference(reference) => {
280                let mut value = serializer.serialize_struct("AttachmentBody", 2)?;
281                value.serialize_field("type", "reference")?;
282                value.serialize_field("reference", reference.as_str())?;
283                value.end()
284            }
285        }
286    }
287}
288
289#[cfg(feature = "serde")]
290impl<'de> serde::Deserialize<'de> for AttachmentBody {
291    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
292    where
293        D: serde::Deserializer<'de>,
294    {
295        use base64::Engine as _;
296
297        #[derive(serde::Deserialize)]
298        #[serde(tag = "type", rename_all = "snake_case")]
299        enum RawAttachmentBody {
300            Bytes { bytes: String },
301            Reference { reference: String },
302        }
303
304        Ok(match RawAttachmentBody::deserialize(deserializer)? {
305            RawAttachmentBody::Bytes { bytes } => {
306                let decoded = base64::engine::general_purpose::STANDARD
307                    .decode(bytes.as_bytes())
308                    .map_err(|err| {
309                        serde::de::Error::custom(format!("invalid base64 attachment bytes: {err}"))
310                    })?;
311                Self::Bytes(decoded)
312            }
313            RawAttachmentBody::Reference { reference } => {
314                Self::Reference(AttachmentReference::new(reference))
315            }
316        })
317    }
318}
319
320#[cfg(feature = "schemars")]
321impl schemars::JsonSchema for AttachmentBody {
322    fn schema_name() -> std::borrow::Cow<'static, str> {
323        "AttachmentBody".into()
324    }
325
326    fn schema_id() -> std::borrow::Cow<'static, str> {
327        concat!(module_path!(), "::AttachmentBody").into()
328    }
329
330    fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
331        schemars::json_schema!({
332            "oneOf": [
333                {
334                    "type": "object",
335                    "properties": {
336                        "type": {"const": "bytes"},
337                        "bytes": {
338                            "type": "string",
339                            "contentEncoding": "base64",
340                            "description": "Base64-encoded attachment bytes (RFC 4648, with padding)"
341                        }
342                    },
343                    "required": ["type", "bytes"]
344                },
345                {
346                    "type": "object",
347                    "properties": {
348                        "type": {"const": "reference"},
349                        "reference": {"type": "string"}
350                    },
351                    "required": ["type", "reference"]
352                }
353            ]
354        })
355    }
356}
357
358/// How a recipient's mail client should present an attachment, per RFC 2183.
359#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
360#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
361#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
362#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
363#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
364#[non_exhaustive]
365pub enum Disposition {
366    /// Render as a normal attachment (downloadable).
367    #[default]
368    Attachment,
369    /// Render inline (referenced by Content-ID, e.g. an image embedded in HTML).
370    Inline,
371}
372
373impl Disposition {
374    /// Returns `true` when the disposition is [`Self::Inline`].
375    #[must_use]
376    pub const fn is_inline(&self) -> bool {
377        matches!(self, Self::Inline)
378    }
379}
380
381/// A MIME attachment and its presentation metadata.
382#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
383#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
384#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
385#[derive(Clone, Debug, PartialEq, Eq)]
386#[non_exhaustive]
387pub struct Attachment {
388    #[cfg_attr(
389        feature = "serde",
390        serde(default, skip_serializing_if = "Option::is_none")
391    )]
392    filename: Option<String>,
393    #[cfg_attr(
394        feature = "schemars",
395        schemars(with = "String", description = "MIME content type")
396    )]
397    content_type: ContentType,
398    #[cfg_attr(
399        feature = "serde",
400        serde(default, skip_serializing_if = "Option::is_none")
401    )]
402    content_id: Option<String>,
403    /// Reads the legacy `"inline": true|false` field via `alias`, with a
404    /// custom deserializer that converts a bool into `Disposition` for one
405    /// migration cycle.
406    #[cfg_attr(
407        feature = "serde",
408        serde(
409            default,
410            alias = "inline",
411            deserialize_with = "deserialize_disposition_compat"
412        )
413    )]
414    #[cfg_attr(feature = "schemars", schemars(default))]
415    disposition: Disposition,
416    body: AttachmentBody,
417}
418
419#[cfg(feature = "serde")]
420fn deserialize_disposition_compat<'de, D>(deserializer: D) -> Result<Disposition, D::Error>
421where
422    D: serde::Deserializer<'de>,
423{
424    use serde::Deserialize as _;
425
426    #[derive(serde::Deserialize)]
427    #[serde(untagged)]
428    enum Compat {
429        Bool(bool),
430        Tag(Disposition),
431    }
432    Ok(match Compat::deserialize(deserializer)? {
433        Compat::Bool(true) => Disposition::Inline,
434        Compat::Bool(false) => Disposition::Attachment,
435        Compat::Tag(d) => d,
436    })
437}
438
439impl Attachment {
440    /// Creates an attachment without filename or content id.
441    #[must_use]
442    pub const fn new(content_type: ContentType, body: AttachmentBody) -> Self {
443        Self {
444            filename: None,
445            content_type,
446            content_id: None,
447            disposition: Disposition::Attachment,
448            body,
449        }
450    }
451
452    /// Creates an attachment backed by in-memory bytes.
453    #[must_use]
454    pub fn bytes(content_type: ContentType, bytes: impl Into<Vec<u8>>) -> Self {
455        Self::new(content_type, AttachmentBody::Bytes(bytes.into()))
456    }
457
458    /// Creates an attachment backed by an unresolved external reference.
459    #[must_use]
460    pub const fn reference(content_type: ContentType, reference: AttachmentReference) -> Self {
461        Self::new(content_type, AttachmentBody::Reference(reference))
462    }
463
464    /// Returns the suggested filename, if set.
465    #[must_use]
466    pub fn filename(&self) -> Option<&str> {
467        self.filename.as_deref()
468    }
469
470    /// Returns the attachment's MIME content type.
471    #[must_use]
472    pub const fn content_type(&self) -> &ContentType {
473        &self.content_type
474    }
475
476    /// Returns the content id used to reference an inline attachment.
477    #[must_use]
478    pub fn content_id(&self) -> Option<&str> {
479        self.content_id.as_deref()
480    }
481
482    /// Returns the requested attachment disposition.
483    #[must_use]
484    pub const fn disposition(&self) -> Disposition {
485        self.disposition
486    }
487
488    /// Returns `true` when the attachment disposition is inline.
489    #[must_use]
490    pub const fn is_inline(&self) -> bool {
491        self.disposition.is_inline()
492    }
493
494    /// Returns the attachment body.
495    #[must_use]
496    pub const fn body(&self) -> &AttachmentBody {
497        &self.body
498    }
499
500    /// Replaces the attachment body in place.
501    pub fn set_body(&mut self, body: AttachmentBody) {
502        self.body = body;
503    }
504
505    /// Builder-style replacement of the attachment body.
506    #[must_use]
507    pub fn with_body(mut self, body: AttachmentBody) -> Self {
508        self.body = body;
509        self
510    }
511
512    /// Sets the suggested attachment filename.
513    #[must_use]
514    pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
515        self.filename = Some(filename.into());
516        self
517    }
518
519    /// Sets the content id used by referring message content.
520    #[must_use]
521    pub fn with_content_id(mut self, content_id: impl Into<String>) -> Self {
522        self.content_id = Some(content_id.into());
523        self
524    }
525
526    /// Sets how recipient clients should present the attachment.
527    #[must_use]
528    pub const fn with_disposition(mut self, disposition: Disposition) -> Self {
529        self.disposition = disposition;
530        self
531    }
532}
533
534/// Message body payload.
535///
536/// # Untrusted-deserialize caveat
537///
538/// The `Body::Mime(MimePart)` variant carries a recursive
539/// `MimePart::Multipart { parts: Vec<Self> }` tree.
540/// Callers deserializing a `Body` (or a [`Message`] containing one)
541/// from untrusted input must pre-bound the input length and recursion
542/// depth: `serde_json` defaults to a 128-frame recursion limit which
543/// is safe, but other formats (e.g. `serde_yaml`, `bincode`,
544/// `rmp-serde`, `serde_cbor`) may not. The wire renderer
545/// (`email_message_wire::render_rfc822`) enforces a
546/// `MAX_MULTIPART_DEPTH` cap on outbound trees, including up to two
547/// frames of attachment-wrapping when inline and/or regular
548/// attachments are present, as a defensive backstop; other consumers
549/// (caller code that walks the tree itself) must defend themselves.
550/// See [`MimePart`] for the matching caveat on the
551/// leaf type.
552#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
553#[derive(Clone, Debug, PartialEq, Eq)]
554#[non_exhaustive]
555pub enum Body {
556    /// A plain-text body.
557    Text(String),
558    /// An HTML body.
559    Html(String),
560    /// Alternative plain-text and HTML representations.
561    TextAndHtml {
562        /// Plain-text representation.
563        text: String,
564        /// HTML representation.
565        html: String,
566    },
567    /// A caller-defined MIME tree.
568    #[cfg(feature = "mime")]
569    Mime(MimePart),
570}
571
572#[cfg(feature = "serde")]
573impl serde::Serialize for Body {
574    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
575    where
576        S: serde::Serializer,
577    {
578        use serde::ser::SerializeStruct as _;
579
580        match self {
581            Self::Text(text) => {
582                let mut value = serializer.serialize_struct("Body", 2)?;
583                value.serialize_field("type", "text")?;
584                value.serialize_field("text", text)?;
585                value.end()
586            }
587            Self::Html(html) => {
588                let mut value = serializer.serialize_struct("Body", 2)?;
589                value.serialize_field("type", "html")?;
590                value.serialize_field("html", html)?;
591                value.end()
592            }
593            Self::TextAndHtml { text, html } => {
594                let mut value = serializer.serialize_struct("Body", 3)?;
595                value.serialize_field("type", "text_and_html")?;
596                value.serialize_field("text", text)?;
597                value.serialize_field("html", html)?;
598                value.end()
599            }
600            #[cfg(feature = "mime")]
601            Self::Mime(part) => {
602                let mut value = serializer.serialize_struct("Body", 2)?;
603                value.serialize_field("type", "mime")?;
604                value.serialize_field("part", part)?;
605                value.end()
606            }
607        }
608    }
609}
610
611#[cfg(feature = "serde")]
612impl<'de> serde::Deserialize<'de> for Body {
613    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
614    where
615        D: serde::Deserializer<'de>,
616    {
617        #[derive(serde::Deserialize)]
618        #[serde(tag = "type", rename_all = "snake_case")]
619        enum RawBody {
620            Text {
621                text: String,
622            },
623            Html {
624                html: String,
625            },
626            TextAndHtml {
627                text: String,
628                html: String,
629            },
630            #[cfg(feature = "mime")]
631            Mime {
632                part: MimePart,
633            },
634        }
635
636        Ok(match RawBody::deserialize(deserializer)? {
637            RawBody::Text { text } => Self::Text(text),
638            RawBody::Html { html } => Self::Html(html),
639            RawBody::TextAndHtml { text, html } => Self::TextAndHtml { text, html },
640            #[cfg(feature = "mime")]
641            RawBody::Mime { part } => Self::Mime(part),
642        })
643    }
644}
645
646#[cfg(feature = "schemars")]
647impl schemars::JsonSchema for Body {
648    fn schema_name() -> std::borrow::Cow<'static, str> {
649        "Body".into()
650    }
651
652    fn schema_id() -> std::borrow::Cow<'static, str> {
653        concat!(module_path!(), "::Body").into()
654    }
655
656    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
657        #[cfg(not(feature = "mime"))]
658        let _ = generator;
659
660        let variants = vec![
661            schemars::json_schema!({
662                "type": "object",
663                "properties": {
664                    "type": {"const": "text"},
665                    "text": {"type": "string"}
666                },
667                "required": ["type", "text"]
668            }),
669            schemars::json_schema!({
670                "type": "object",
671                "properties": {
672                    "type": {"const": "html"},
673                    "html": {"type": "string"}
674                },
675                "required": ["type", "html"]
676            }),
677            schemars::json_schema!({
678                "type": "object",
679                "properties": {
680                    "type": {"const": "text_and_html"},
681                    "text": {"type": "string"},
682                    "html": {"type": "string"}
683                },
684                "required": ["type", "text", "html"]
685            }),
686        ];
687
688        #[cfg(feature = "mime")]
689        let variants = {
690            let mut variants = variants;
691            let part = generator.subschema_for::<MimePart>();
692            variants.push(schemars::json_schema!({
693                "type": "object",
694                "properties": {
695                    "type": {"const": "mime"},
696                    "part": part
697                },
698                "required": ["type", "part"]
699            }));
700            variants
701        };
702
703        schemars::json_schema!({"oneOf": variants})
704    }
705}
706
707impl Body {
708    /// Creates a plain-text body.
709    #[must_use]
710    pub fn text(value: impl Into<String>) -> Self {
711        Self::Text(value.into())
712    }
713
714    /// Creates an HTML body.
715    #[must_use]
716    pub fn html(value: impl Into<String>) -> Self {
717        Self::Html(value.into())
718    }
719
720    /// Creates alternative plain-text and HTML body representations.
721    #[must_use]
722    pub fn text_and_html(text: impl Into<String>, html: impl Into<String>) -> Self {
723        Self::TextAndHtml {
724            text: text.into(),
725            html: html.into(),
726        }
727    }
728}
729
730/// Parsed message content and headers.
731///
732/// # Validation
733///
734/// `Message` validation is split between this crate and the wire layer:
735///
736/// - [`Message::validate_basic`] enforces structural invariants:
737///   `From` is set, `Sender` is not set without `From`, at least one
738///   recipient in `To`/`Cc`/`Bcc`, the subject contains no raw `\r`,
739///   `\n`, or non-tab control characters, and no custom header
740///   collides with a structured field (`Subject`, `Message-ID`, …).
741/// - Per-field RFC 5322 invariants (line length, RFC 2047 encoded-word
742///   wrapping, ASCII-after-encoding, header folding) are enforced by
743///   `email_message_wire::render_rfc822` for SMTP paths.
744/// - HTTP-API adapters (Postmark, Resend, Mailgun, Loops) bypass the
745///   wire renderer and rely on `serde_json` string-escaping for
746///   control-char neutralization in JSON bodies.
747///
748/// Adapters that bypass both the wire renderer and a JSON-encoded
749/// transport must validate header values themselves.
750#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
751#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
752#[derive(Clone, Debug, PartialEq, Eq)]
753#[allow(clippy::struct_field_names)]
754#[non_exhaustive]
755pub struct Message {
756    #[cfg_attr(
757        feature = "serde",
758        serde(default, skip_serializing_if = "Option::is_none")
759    )]
760    from: Option<Mailbox>,
761    #[cfg_attr(
762        feature = "serde",
763        serde(default, skip_serializing_if = "Option::is_none")
764    )]
765    sender: Option<Mailbox>,
766    #[cfg_attr(
767        feature = "serde",
768        serde(default, skip_serializing_if = "Vec::is_empty")
769    )]
770    #[cfg_attr(feature = "schemars", schemars(default))]
771    to: Vec<Address>,
772    #[cfg_attr(
773        feature = "serde",
774        serde(default, skip_serializing_if = "Vec::is_empty")
775    )]
776    #[cfg_attr(feature = "schemars", schemars(default))]
777    cc: Vec<Address>,
778    #[cfg_attr(
779        feature = "serde",
780        serde(default, skip_serializing_if = "Vec::is_empty")
781    )]
782    #[cfg_attr(feature = "schemars", schemars(default))]
783    bcc: Vec<Address>,
784    #[cfg_attr(
785        feature = "serde",
786        serde(default, skip_serializing_if = "Vec::is_empty")
787    )]
788    #[cfg_attr(feature = "schemars", schemars(default))]
789    reply_to: Vec<Address>,
790    #[cfg_attr(
791        feature = "serde",
792        serde(default, skip_serializing_if = "Option::is_none")
793    )]
794    subject: Option<String>,
795    #[cfg_attr(
796        feature = "serde",
797        serde(default, skip_serializing_if = "Option::is_none")
798    )]
799    #[cfg_attr(
800        feature = "schemars",
801        schemars(with = "Option<String>", description = "RFC 2822 date-time")
802    )]
803    date: Option<OffsetDateTime>,
804    #[cfg_attr(
805        feature = "serde",
806        serde(default, skip_serializing_if = "Option::is_none")
807    )]
808    message_id: Option<MessageId>,
809    #[cfg_attr(
810        feature = "serde",
811        serde(default, skip_serializing_if = "Vec::is_empty")
812    )]
813    #[cfg_attr(feature = "schemars", schemars(default))]
814    headers: Vec<Header>,
815    body: Body,
816    #[cfg_attr(
817        feature = "serde",
818        serde(default, skip_serializing_if = "Vec::is_empty")
819    )]
820    #[cfg_attr(feature = "schemars", schemars(default))]
821    attachments: Vec<Attachment>,
822}
823
824/// A [`Message`] that has passed outbound delivery validation.
825///
826/// The serde representation matches [`Message`] verbatim; deserializing
827/// runs [`OutboundMessage::new`] so an invalid payload is rejected
828/// instead of silently bypassing the typestate invariant.
829#[derive(Clone, Debug, PartialEq, Eq)]
830#[non_exhaustive]
831pub struct OutboundMessage {
832    /// The validated underlying message.
833    inner: Message,
834    /// The `From` mailbox, mirroring `inner.from`. Stored separately so
835    /// [`Self::from_mailbox`] can return `&Mailbox` infallibly without
836    /// unwrapping; [`Self::new`] establishes the invariant
837    /// `inner.from == Some(from.clone())`.
838    from: Mailbox,
839}
840
841#[cfg(feature = "serde")]
842impl serde::Serialize for OutboundMessage {
843    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
844    where
845        S: serde::Serializer,
846    {
847        // Transparent over `Message`: the redundant `from` field is
848        // an in-memory accessor cache, not part of the wire format.
849        self.inner.serialize(serializer)
850    }
851}
852
853#[cfg(feature = "serde")]
854impl<'de> serde::Deserialize<'de> for OutboundMessage {
855    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
856    where
857        D: serde::Deserializer<'de>,
858    {
859        let message = Message::deserialize(deserializer)?;
860        Self::new(message).map_err(serde::de::Error::custom)
861    }
862}
863
864#[cfg(feature = "schemars")]
865impl schemars::JsonSchema for OutboundMessage {
866    fn schema_name() -> std::borrow::Cow<'static, str> {
867        <Message as schemars::JsonSchema>::schema_name()
868    }
869
870    fn schema_id() -> std::borrow::Cow<'static, str> {
871        <Message as schemars::JsonSchema>::schema_id()
872    }
873
874    fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
875        <Message as schemars::JsonSchema>::json_schema(generator)
876    }
877}
878
879impl OutboundMessage {
880    /// Validate and wrap a message for outbound delivery.
881    ///
882    /// # Errors
883    ///
884    /// Returns [`MessageValidationError`] when required outbound fields are
885    /// missing or inconsistent.
886    pub fn new(message: Message) -> Result<Self, MessageValidationError> {
887        message.validate_basic()?;
888        // `validate_basic` already guarantees `from` is `Some`. The
889        // redundant `ok_or` is defensive, it preserves the no-panic
890        // contract on this constructor even under hypothetical future
891        // contract drift in `validate_basic`.
892        let from = message
893            .from
894            .clone()
895            .ok_or(MessageValidationError::MissingFrom)?;
896        Ok(Self {
897            inner: message,
898            from,
899        })
900    }
901
902    /// Returns the validated message.
903    #[must_use]
904    pub const fn as_message(&self) -> &Message {
905        &self.inner
906    }
907
908    /// Consumes the wrapper and returns the validated message.
909    #[must_use]
910    pub fn into_message(self) -> Message {
911        self.inner
912    }
913
914    /// Returns the validated `From` mailbox.
915    ///
916    /// Outbound validation guarantees the `From` field is set, so this
917    /// accessor is infallible (unlike [`Message::from_mailbox`], which
918    /// returns `Option<&Mailbox>`).
919    #[must_use]
920    pub const fn from_mailbox(&self) -> &Mailbox {
921        &self.from
922    }
923}
924
925impl TryFrom<Message> for OutboundMessage {
926    type Error = MessageValidationError;
927
928    fn try_from(value: Message) -> Result<Self, Self::Error> {
929        Self::new(value)
930    }
931}
932
933impl From<OutboundMessage> for Message {
934    fn from(value: OutboundMessage) -> Self {
935        value.inner
936    }
937}
938
939#[cfg(feature = "arbitrary")]
940impl<'a> arbitrary::Arbitrary<'a> for Message {
941    fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
942        let has_date = bool::arbitrary(u)?;
943        let date = if has_date {
944            let seconds = i64::arbitrary(u)?;
945            Some(OffsetDateTime::from_unix_timestamp(seconds).unwrap_or(OffsetDateTime::UNIX_EPOCH))
946        } else {
947            None
948        };
949
950        Ok(Self {
951            from: Option::<Mailbox>::arbitrary(u)?,
952            sender: Option::<Mailbox>::arbitrary(u)?,
953            to: Vec::<Address>::arbitrary(u)?,
954            cc: Vec::<Address>::arbitrary(u)?,
955            bcc: Vec::<Address>::arbitrary(u)?,
956            reply_to: Vec::<Address>::arbitrary(u)?,
957            subject: Option::<String>::arbitrary(u)?,
958            date,
959            message_id: Option::<MessageId>::arbitrary(u)?,
960            headers: Vec::<Header>::arbitrary(u)?,
961            body: Body::arbitrary(u)?,
962            attachments: Vec::<Attachment>::arbitrary(u)?,
963        })
964    }
965}
966
967/// Reasons a [`Message`] failed [`Message::validate_basic`] (and therefore
968/// cannot be promoted into an [`OutboundMessage`]).
969///
970/// ```rust
971/// use email_message::{Address, Body, Header, Mailbox, Message, MessageValidationError};
972///
973/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
974/// let from: Mailbox = "alice@example.com".parse()?;
975/// let to = Address::Mailbox("bob@example.com".parse()?);
976///
977/// // A subject carrying a CRLF injection is rejected at build time:
978/// let error = Message::builder(Body::text("hello"))
979///     .from_mailbox(from.clone())
980///     .add_to(to.clone())
981///     .subject("hi\r\nBcc: attacker@example.com")
982///     .build()
983///     .unwrap_err();
984/// assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
985///
986/// // A custom header that collides with a structured field is rejected:
987/// let error = Message::builder(Body::text("hello"))
988///     .from_mailbox(from)
989///     .add_to(to)
990///     .add_header(Header::new("Subject", "shadow")?)
991///     .build()
992///     .unwrap_err();
993/// assert!(matches!(
994///     error,
995///     MessageValidationError::ReservedHeaderName { ref name, .. } if name == "Subject"
996/// ));
997/// # Ok(())
998/// # }
999/// ```
1000#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
1001#[non_exhaustive]
1002pub enum MessageValidationError {
1003    /// The message does not have a `From` mailbox.
1004    #[error("missing From header")]
1005    MissingFrom,
1006    /// A `Sender` mailbox is set without a `From` mailbox.
1007    #[error("sender header cannot appear without from")]
1008    SenderWithoutFrom,
1009    /// None of `To`, `Cc`, or `Bcc` contains a recipient.
1010    #[error("no recipients in To/Cc/Bcc")]
1011    MissingRecipients,
1012    /// A custom header duplicates a structured message field.
1013    #[error(
1014        "custom header `{name}` collides with a structured field; use the typed setter (Subject, Date, Message-ID, From, ...) instead"
1015    )]
1016    #[non_exhaustive]
1017    ReservedHeaderName {
1018        /// Conflicting custom header name.
1019        name: String,
1020    },
1021    /// The subject contains a raw newline or forbidden control character.
1022    #[error("subject contains raw CR, LF, or non-tab control characters")]
1023    SubjectContainsInvalidChars,
1024    /// A mailbox or group display name contains header-unsafe characters.
1025    #[error(
1026        "mailbox display name in `{location}` contains raw CR, LF, NUL, or non-tab control characters"
1027    )]
1028    #[non_exhaustive]
1029    MailboxDisplayNameContainsInvalidChars {
1030        /// Message field containing the invalid display name.
1031        location: &'static str,
1032    },
1033    /// Attachment metadata contains header-unsafe characters.
1034    #[error(
1035        "attachment metadata field `{field}` contains raw CR, LF, NUL, or non-tab control characters"
1036    )]
1037    #[non_exhaustive]
1038    AttachmentMetadataContainsInvalidChars {
1039        /// Invalid attachment metadata field.
1040        field: &'static str,
1041    },
1042}
1043
1044fn contains_header_unsafe_chars(value: &str) -> bool {
1045    value
1046        .bytes()
1047        .any(|byte| byte == b'\r' || byte == b'\n' || (byte != b'\t' && byte.is_ascii_control()))
1048}
1049
1050/// Returns `Err` when any mailbox in `addresses` carries a display name with
1051/// raw CR / LF / NUL / non-tab control characters, applying the same byte
1052/// discipline as [`contains_header_unsafe_chars`]. Group display names and
1053/// group members are walked recursively.
1054fn validate_address_mailboxes(
1055    addresses: &[Address],
1056    location: &'static str,
1057) -> Result<(), MessageValidationError> {
1058    for address in addresses {
1059        match address {
1060            Address::Mailbox(mailbox) => {
1061                if let Some(name) = mailbox.name()
1062                    && contains_header_unsafe_chars(name)
1063                {
1064                    return Err(
1065                        MessageValidationError::MailboxDisplayNameContainsInvalidChars { location },
1066                    );
1067                }
1068            }
1069            Address::Group(group) => {
1070                if contains_header_unsafe_chars(group.name()) {
1071                    return Err(
1072                        MessageValidationError::MailboxDisplayNameContainsInvalidChars { location },
1073                    );
1074                }
1075                for member in group.members() {
1076                    if let Some(name) = member.name()
1077                        && contains_header_unsafe_chars(name)
1078                    {
1079                        return Err(
1080                            MessageValidationError::MailboxDisplayNameContainsInvalidChars {
1081                                location,
1082                            },
1083                        );
1084                    }
1085                }
1086            }
1087        }
1088    }
1089    Ok(())
1090}
1091
1092fn validate_mailbox_display_name(
1093    mailbox: &Mailbox,
1094    location: &'static str,
1095) -> Result<(), MessageValidationError> {
1096    if let Some(name) = mailbox.name()
1097        && contains_header_unsafe_chars(name)
1098    {
1099        return Err(MessageValidationError::MailboxDisplayNameContainsInvalidChars { location });
1100    }
1101    Ok(())
1102}
1103
1104/// RFC 5322 §3.6 mandates these headers appear at most once. The kernel
1105/// exposes typed setters for each; populating them through
1106/// `MessageBuilder::header` would either duplicate the field or shadow it
1107/// at the wire layer.
1108///
1109/// `In-Reply-To` and `References` are also §3.6 singletons but are
1110/// deliberately *not* on this list because the kernel has no typed setter
1111/// for them yet. Until that gap closes, callers must use
1112/// `MessageBuilder::header` for those.
1113const RESERVED_HEADER_NAMES: &[&str] = &[
1114    "from",
1115    "sender",
1116    "reply-to",
1117    "to",
1118    "cc",
1119    "bcc",
1120    "date",
1121    "subject",
1122    "message-id",
1123];
1124
1125fn is_reserved_header_name(name: &str) -> bool {
1126    RESERVED_HEADER_NAMES
1127        .iter()
1128        .any(|reserved| name.eq_ignore_ascii_case(reserved))
1129}
1130
1131impl Message {
1132    /// Creates a message with required semantic fields.
1133    #[must_use]
1134    pub const fn new(from: Mailbox, to: Vec<Address>, body: Body) -> Self {
1135        Self {
1136            from: Some(from),
1137            sender: None,
1138            to,
1139            cc: Vec::new(),
1140            bcc: Vec::new(),
1141            reply_to: Vec::new(),
1142            subject: None,
1143            date: None,
1144            message_id: None,
1145            headers: Vec::new(),
1146            body,
1147            attachments: Vec::new(),
1148        }
1149    }
1150
1151    /// Returns a builder for incrementally constructing messages.
1152    #[must_use]
1153    pub const fn builder(body: Body) -> MessageBuilder {
1154        MessageBuilder::new(body)
1155    }
1156
1157    /// Returns the optional `From` mailbox, if one has been set.
1158    ///
1159    /// `OutboundMessage` validation guarantees `From` is present; for
1160    /// already-validated messages, prefer [`OutboundMessage::from_mailbox`]
1161    /// which returns `&Mailbox` directly.
1162    #[must_use]
1163    pub const fn from_mailbox(&self) -> Option<&Mailbox> {
1164        self.from.as_ref()
1165    }
1166
1167    #[must_use]
1168    /// Returns the optional `Sender` mailbox.
1169    pub const fn sender(&self) -> Option<&Mailbox> {
1170        self.sender.as_ref()
1171    }
1172
1173    #[must_use]
1174    /// Returns the `To` recipients.
1175    pub fn to(&self) -> &[Address] {
1176        self.to.as_slice()
1177    }
1178
1179    #[must_use]
1180    /// Returns the `Cc` recipients.
1181    pub fn cc(&self) -> &[Address] {
1182        self.cc.as_slice()
1183    }
1184
1185    #[must_use]
1186    /// Returns the `Bcc` recipients.
1187    pub fn bcc(&self) -> &[Address] {
1188        self.bcc.as_slice()
1189    }
1190
1191    #[must_use]
1192    /// Returns the `Reply-To` addresses.
1193    pub fn reply_to(&self) -> &[Address] {
1194        self.reply_to.as_slice()
1195    }
1196
1197    #[must_use]
1198    /// Returns the subject, if set.
1199    pub fn subject(&self) -> Option<&str> {
1200        self.subject.as_deref()
1201    }
1202
1203    #[must_use]
1204    /// Returns the message date, if set.
1205    pub const fn date(&self) -> Option<&OffsetDateTime> {
1206        self.date.as_ref()
1207    }
1208
1209    #[must_use]
1210    /// Returns the message id, if set.
1211    pub const fn message_id(&self) -> Option<&MessageId> {
1212        self.message_id.as_ref()
1213    }
1214
1215    #[must_use]
1216    /// Returns custom headers in insertion order.
1217    pub fn headers(&self) -> &[Header] {
1218        self.headers.as_slice()
1219    }
1220
1221    #[must_use]
1222    /// Returns the message body.
1223    pub const fn body(&self) -> &Body {
1224        &self.body
1225    }
1226
1227    #[must_use]
1228    /// Returns message attachments in insertion order.
1229    pub fn attachments(&self) -> &[Attachment] {
1230        self.attachments.as_slice()
1231    }
1232
1233    #[must_use]
1234    /// Replaces all message attachments.
1235    pub fn with_attachments<I>(mut self, attachments: I) -> Self
1236    where
1237        I: IntoIterator<Item = Attachment>,
1238    {
1239        self.attachments = attachments.into_iter().collect();
1240        self
1241    }
1242
1243    /// Split the message into an attachment-free message and its attachments.
1244    #[must_use]
1245    pub fn into_attachments(mut self) -> (Self, Vec<Attachment>) {
1246        let attachments = std::mem::take(&mut self.attachments);
1247        (self, attachments)
1248    }
1249
1250    /// Validates baseline message invariants.
1251    ///
1252    /// # Coverage
1253    ///
1254    /// The gate covers top-level message fields (`from`, `sender`,
1255    /// recipients, `subject`, custom `headers`) and the `attachments`
1256    /// list (filename and content-id byte discipline). It does **not**
1257    /// recurse into [`Body::Mime`] payloads: MIME-tree fields the typed
1258    /// wrappers leave unvalidated at construction (notably
1259    /// `MimePart::Multipart`'s `boundary: Option<String>`, which is
1260    /// lazy-checked by the wire renderer's `validate_boundary` at
1261    /// render time, and `MimePart::Leaf`'s raw `body: Vec<u8>`, which
1262    /// is transfer-encoded at render time) are not inspected here.
1263    /// Such bytes are caught by the wire renderer at
1264    /// `email_message_wire::render_rfc822`'s header-emission and
1265    /// boundary-validation stages, which reject raw CR/LF and non-ASCII
1266    /// at write time. Walking the entire `MimePart` tree in this method
1267    /// would make the gate quadratic on attacker-controlled depth, the
1268    /// inverse of the renderer's own `MAX_MULTIPART_DEPTH` cap.
1269    ///
1270    /// # Errors
1271    ///
1272    /// Returns [`MessageValidationError`] when required message fields are
1273    /// missing or inconsistent.
1274    pub fn validate_basic(&self) -> Result<(), MessageValidationError> {
1275        if self.sender.is_some() && self.from.is_none() {
1276            return Err(MessageValidationError::SenderWithoutFrom);
1277        }
1278
1279        if self.from.is_none() {
1280            return Err(MessageValidationError::MissingFrom);
1281        }
1282
1283        if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
1284            return Err(MessageValidationError::MissingRecipients);
1285        }
1286
1287        if let Some(subject) = self.subject.as_deref()
1288            && contains_header_unsafe_chars(subject)
1289        {
1290            return Err(MessageValidationError::SubjectContainsInvalidChars);
1291        }
1292
1293        for header in &self.headers {
1294            if is_reserved_header_name(header.name()) {
1295                return Err(MessageValidationError::ReservedHeaderName {
1296                    name: header.name().to_owned(),
1297                });
1298            }
1299        }
1300
1301        if let Some(from) = self.from.as_ref() {
1302            validate_mailbox_display_name(from, "from")?;
1303        }
1304        if let Some(sender) = self.sender.as_ref() {
1305            validate_mailbox_display_name(sender, "sender")?;
1306        }
1307        validate_address_mailboxes(&self.to, "to")?;
1308        validate_address_mailboxes(&self.cc, "cc")?;
1309        validate_address_mailboxes(&self.bcc, "bcc")?;
1310        validate_address_mailboxes(&self.reply_to, "reply-to")?;
1311
1312        for attachment in &self.attachments {
1313            if let Some(filename) = attachment.filename()
1314                && contains_header_unsafe_chars(filename)
1315            {
1316                return Err(
1317                    MessageValidationError::AttachmentMetadataContainsInvalidChars {
1318                        field: "filename",
1319                    },
1320                );
1321            }
1322            if let Some(content_id) = attachment.content_id()
1323                && contains_header_unsafe_chars(content_id)
1324            {
1325                return Err(
1326                    MessageValidationError::AttachmentMetadataContainsInvalidChars {
1327                        field: "content-id",
1328                    },
1329                );
1330            }
1331        }
1332
1333        Ok(())
1334    }
1335
1336    /// Derives an SMTP envelope from message semantics.
1337    ///
1338    /// # Errors
1339    ///
1340    /// Returns [`MessageValidationError`] when the message does not contain the
1341    /// fields needed to derive an envelope.
1342    pub fn derive_envelope(&self) -> Result<Envelope, MessageValidationError> {
1343        self.validate_basic()?;
1344
1345        let mail_from = self
1346            .sender
1347            .as_ref()
1348            .or(self.from.as_ref())
1349            .map(|mailbox| mailbox.email().clone());
1350
1351        let mut rcpt_to = Vec::new();
1352        extend_recipient_emails(&mut rcpt_to, &self.to);
1353        extend_recipient_emails(&mut rcpt_to, &self.cc);
1354        extend_recipient_emails(&mut rcpt_to, &self.bcc);
1355
1356        Ok(Envelope::new(mail_from, rcpt_to))
1357    }
1358}
1359
1360fn extend_recipient_emails(out: &mut Vec<EmailAddress>, addresses: &[Address]) {
1361    for address in addresses {
1362        out.extend(address.mailboxes().map(|mailbox| mailbox.email().clone()));
1363    }
1364}
1365
1366/// Builder for [`Message`].
1367#[derive(Clone, Debug, PartialEq, Eq)]
1368#[non_exhaustive]
1369pub struct MessageBuilder {
1370    message: Message,
1371}
1372
1373impl MessageBuilder {
1374    /// Creates a builder with the required message body.
1375    #[must_use]
1376    pub const fn new(body: Body) -> Self {
1377        Self {
1378            message: Message {
1379                from: None,
1380                sender: None,
1381                to: Vec::new(),
1382                cc: Vec::new(),
1383                bcc: Vec::new(),
1384                reply_to: Vec::new(),
1385                subject: None,
1386                date: None,
1387                message_id: None,
1388                headers: Vec::new(),
1389                body,
1390                attachments: Vec::new(),
1391            },
1392        }
1393    }
1394
1395    /// Sets the `From` mailbox.
1396    ///
1397    /// Named `from_mailbox` (rather than `from`) to avoid shadowing the
1398    /// [`From::from`] trait method and the [`Message::from_mailbox`] accessor.
1399    #[must_use]
1400    pub fn from_mailbox(mut self, from: Mailbox) -> Self {
1401        self.message.from = Some(from);
1402        self
1403    }
1404
1405    /// Sets the optional `Sender` mailbox.
1406    #[must_use]
1407    pub fn sender(mut self, sender: Mailbox) -> Self {
1408        self.message.sender = Some(sender);
1409        self
1410    }
1411
1412    /// Replace the entire `To` recipient list. To append a single recipient,
1413    /// use [`Self::add_to`].
1414    #[must_use]
1415    pub fn to<I>(mut self, to: I) -> Self
1416    where
1417        I: IntoIterator<Item = Address>,
1418    {
1419        self.message.to = to.into_iter().collect();
1420        self
1421    }
1422
1423    /// Append a recipient to the `To` list.
1424    #[must_use]
1425    pub fn add_to(mut self, to: impl Into<Address>) -> Self {
1426        self.message.to.push(to.into());
1427        self
1428    }
1429
1430    /// Replace the entire `Cc` recipient list. To append, use [`Self::add_cc`].
1431    #[must_use]
1432    pub fn cc<I>(mut self, cc: I) -> Self
1433    where
1434        I: IntoIterator<Item = Address>,
1435    {
1436        self.message.cc = cc.into_iter().collect();
1437        self
1438    }
1439
1440    /// Append a recipient to the `Cc` list.
1441    #[must_use]
1442    pub fn add_cc(mut self, cc: impl Into<Address>) -> Self {
1443        self.message.cc.push(cc.into());
1444        self
1445    }
1446
1447    /// Replace the entire `Bcc` recipient list. To append, use [`Self::add_bcc`].
1448    #[must_use]
1449    pub fn bcc<I>(mut self, bcc: I) -> Self
1450    where
1451        I: IntoIterator<Item = Address>,
1452    {
1453        self.message.bcc = bcc.into_iter().collect();
1454        self
1455    }
1456
1457    /// Append a recipient to the `Bcc` list.
1458    #[must_use]
1459    pub fn add_bcc(mut self, bcc: impl Into<Address>) -> Self {
1460        self.message.bcc.push(bcc.into());
1461        self
1462    }
1463
1464    /// Replace the entire `Reply-To` list.
1465    #[must_use]
1466    pub fn reply_to<I>(mut self, reply_to: I) -> Self
1467    where
1468        I: IntoIterator<Item = Address>,
1469    {
1470        self.message.reply_to = reply_to.into_iter().collect();
1471        self
1472    }
1473
1474    /// Append a recipient to the `Reply-To` list.
1475    #[must_use]
1476    pub fn add_reply_to(mut self, reply_to: impl Into<Address>) -> Self {
1477        self.message.reply_to.push(reply_to.into());
1478        self
1479    }
1480
1481    /// Sets the optional subject.
1482    #[must_use]
1483    pub fn subject(mut self, subject: impl Into<String>) -> Self {
1484        self.message.subject = Some(subject.into());
1485        self
1486    }
1487
1488    /// Sets the optional message date.
1489    #[must_use]
1490    pub const fn date(mut self, date: OffsetDateTime) -> Self {
1491        self.message.date = Some(date);
1492        self
1493    }
1494
1495    /// Sets the optional message id.
1496    #[must_use]
1497    pub fn message_id(mut self, message_id: MessageId) -> Self {
1498        self.message.message_id = Some(message_id);
1499        self
1500    }
1501
1502    /// Replaces all custom headers.
1503    #[must_use]
1504    pub fn headers<I>(mut self, headers: I) -> Self
1505    where
1506        I: IntoIterator<Item = Header>,
1507    {
1508        self.message.headers = headers.into_iter().collect();
1509        self
1510    }
1511
1512    /// Append a single custom header.
1513    #[must_use]
1514    pub fn add_header(mut self, header: Header) -> Self {
1515        self.message.headers.push(header);
1516        self
1517    }
1518
1519    /// Replaces all attachments.
1520    #[must_use]
1521    pub fn attachments<I>(mut self, attachments: I) -> Self
1522    where
1523        I: IntoIterator<Item = Attachment>,
1524    {
1525        self.message.attachments = attachments.into_iter().collect();
1526        self
1527    }
1528
1529    /// Append a single attachment.
1530    #[must_use]
1531    pub fn add_attachment(mut self, attachment: Attachment) -> Self {
1532        self.message.attachments.push(attachment);
1533        self
1534    }
1535
1536    /// Returns the underlying `Message` without running outbound
1537    /// validation.
1538    ///
1539    /// Reserved for paths that construct a `Message` from already-parsed
1540    /// inbound data, for example `email_message_wire::parse_rfc822`,
1541    /// where the wire-format invariants come from the parser and the
1542    /// outbound rules (`From` set, at least one recipient, no reserved
1543    /// header collisions, no CRLF in subject) are not meaningful.
1544    ///
1545    /// **Outbound callers should use [`Self::build`] or
1546    /// [`Self::build_outbound`] instead.** Wrapping the result of
1547    /// `build_unchecked` in `OutboundMessage::new` re-runs the validation
1548    /// you skipped, with no benefit.
1549    #[must_use]
1550    pub fn build_unchecked(self) -> Message {
1551        self.message
1552    }
1553
1554    /// Build and validate the message.
1555    ///
1556    /// # Errors
1557    ///
1558    /// Returns [`MessageValidationError`] when required message fields are
1559    /// missing or inconsistent.
1560    pub fn build(self) -> Result<Message, MessageValidationError> {
1561        self.message.validate_basic()?;
1562        Ok(self.message)
1563    }
1564
1565    /// Build, validate, and wrap the message for outbound delivery.
1566    ///
1567    /// # Errors
1568    ///
1569    /// Returns [`MessageValidationError`] when required message fields are
1570    /// missing or inconsistent.
1571    pub fn build_outbound(self) -> Result<OutboundMessage, MessageValidationError> {
1572        OutboundMessage::new(self.message)
1573    }
1574}
1575
1576#[cfg(test)]
1577mod tests {
1578    use super::*;
1579    use time::format_description::well_known::Rfc2822;
1580
1581    fn mailbox(input: &str) -> Mailbox {
1582        input.parse::<Mailbox>().expect("mailbox should parse")
1583    }
1584
1585    fn address(input: &str) -> Address {
1586        input.parse::<Address>().expect("address should parse")
1587    }
1588
1589    #[test]
1590    fn validate_basic_reports_sender_without_from() {
1591        let error = Message::builder(Body::text("body"))
1592            .sender(mailbox("sender@example.com"))
1593            .add_to(address("to@example.com"))
1594            .build()
1595            .expect_err("message should be invalid");
1596
1597        assert_eq!(error, MessageValidationError::SenderWithoutFrom);
1598    }
1599
1600    #[test]
1601    fn validate_basic_rejects_reserved_header_names() {
1602        let error = Message::builder(Body::text("body"))
1603            .from_mailbox(mailbox("from@example.com"))
1604            .add_to(address("to@example.com"))
1605            .add_header(Header::new("Subject", "shadow").expect("header should validate"))
1606            .build()
1607            .expect_err("reserved header should be rejected");
1608
1609        assert!(matches!(
1610            error,
1611            MessageValidationError::ReservedHeaderName { ref name, .. } if name == "Subject"
1612        ));
1613    }
1614
1615    #[test]
1616    fn validate_basic_rejects_reserved_header_case_insensitively() {
1617        let error = Message::builder(Body::text("body"))
1618            .from_mailbox(mailbox("from@example.com"))
1619            .add_to(address("to@example.com"))
1620            .add_header(Header::new("MESSAGE-ID", "<x@y>").expect("header should validate"))
1621            .build()
1622            .expect_err("reserved header should be rejected");
1623
1624        assert!(matches!(
1625            error,
1626            MessageValidationError::ReservedHeaderName { ref name, .. } if name == "MESSAGE-ID"
1627        ));
1628    }
1629
1630    #[test]
1631    fn validate_basic_rejects_subject_with_crlf_injection() {
1632        let error = Message::builder(Body::text("body"))
1633            .from_mailbox(mailbox("from@example.com"))
1634            .add_to(address("to@example.com"))
1635            .subject("hi\r\nBcc: victim@example.com")
1636            .build()
1637            .expect_err("subject CRLF injection should be rejected");
1638
1639        assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1640    }
1641
1642    #[test]
1643    fn validate_basic_rejects_subject_with_bare_lf() {
1644        let error = Message::builder(Body::text("body"))
1645            .from_mailbox(mailbox("from@example.com"))
1646            .add_to(address("to@example.com"))
1647            .subject("hi\nbcc")
1648            .build()
1649            .expect_err("subject bare LF should be rejected");
1650
1651        assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1652    }
1653
1654    #[test]
1655    fn validate_basic_rejects_subject_with_control_char() {
1656        let error = Message::builder(Body::text("body"))
1657            .from_mailbox(mailbox("from@example.com"))
1658            .add_to(address("to@example.com"))
1659            .subject("hi\x07boss")
1660            .build()
1661            .expect_err("subject control char should be rejected");
1662
1663        assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1664    }
1665
1666    #[test]
1667    fn validate_basic_rejects_from_mailbox_with_crlf_in_display_name() {
1668        let email = "alice@example.com"
1669            .parse::<EmailAddress>()
1670            .expect("email parses");
1671        let hostile_from = Mailbox::from(("evil\r\nBcc: attacker@example.com".to_string(), email));
1672
1673        let error = Message::builder(Body::text("body"))
1674            .from_mailbox(hostile_from)
1675            .add_to(address("to@example.com"))
1676            .build()
1677            .expect_err("hostile From display name should be rejected");
1678
1679        assert!(matches!(
1680            error,
1681            MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1682        ));
1683    }
1684
1685    #[test]
1686    fn validate_basic_rejects_to_mailbox_with_lf_in_display_name() {
1687        let email = "victim@example.com"
1688            .parse::<EmailAddress>()
1689            .expect("email parses");
1690        let hostile_to = Address::Mailbox(Mailbox::from(("name\ninjection".to_string(), email)));
1691
1692        let error = Message::builder(Body::text("body"))
1693            .from_mailbox(mailbox("from@example.com"))
1694            .add_to(hostile_to)
1695            .build()
1696            .expect_err("hostile To display name should be rejected");
1697
1698        assert!(matches!(
1699            error,
1700            MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1701        ));
1702    }
1703
1704    #[test]
1705    fn validate_basic_rejects_group_member_with_nul_in_display_name() {
1706        // Construct a Group via parse, then we'd need to inject, but Group's
1707        // members are private. Instead test the group's own display name path
1708        // by parsing a group with a hostile member display name impossible
1709        // through parse (parse rejects raw newlines), so we test the
1710        // mailbox-via-cc path which is the realistic case.
1711        let email = "member@example.com"
1712            .parse::<EmailAddress>()
1713            .expect("email parses");
1714        let hostile_cc = Address::Mailbox(Mailbox::from(("embed\0nul".to_string(), email)));
1715
1716        let error = Message::builder(Body::text("body"))
1717            .from_mailbox(mailbox("from@example.com"))
1718            .add_cc(hostile_cc)
1719            .build()
1720            .expect_err("hostile Cc display name should be rejected");
1721
1722        assert!(matches!(
1723            error,
1724            MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1725        ));
1726    }
1727
1728    #[test]
1729    fn validate_basic_accepts_mailbox_with_tab_in_display_name() {
1730        let email = "alice@example.com"
1731            .parse::<EmailAddress>()
1732            .expect("email parses");
1733        let from = Mailbox::from(("Alice\tBob".to_string(), email));
1734
1735        Message::builder(Body::text("body"))
1736            .from_mailbox(from)
1737            .add_to(address("to@example.com"))
1738            .build()
1739            .expect("tab in display name should be accepted");
1740    }
1741
1742    #[test]
1743    fn validate_basic_accepts_subject_with_tab() {
1744        let message = Message::builder(Body::text("body"))
1745            .from_mailbox(mailbox("from@example.com"))
1746            .add_to(address("to@example.com"))
1747            .subject("hi\tworld")
1748            .build()
1749            .expect("subject with tab should be accepted");
1750
1751        assert_eq!(message.subject(), Some("hi\tworld"));
1752    }
1753
1754    #[test]
1755    fn outbound_message_from_mailbox_returns_validated_field() {
1756        let outbound = Message::builder(Body::text("body"))
1757            .from_mailbox(mailbox("alice@example.com"))
1758            .add_to(address("bob@example.com"))
1759            .build_outbound()
1760            .expect("message should validate");
1761
1762        assert_eq!(
1763            outbound.from_mailbox().email().as_str(),
1764            "alice@example.com"
1765        );
1766    }
1767
1768    #[cfg(feature = "serde")]
1769    #[test]
1770    fn outbound_message_serde_format_matches_message() {
1771        let outbound = Message::builder(Body::text("body"))
1772            .from_mailbox(mailbox("alice@example.com"))
1773            .add_to(address("bob@example.com"))
1774            .subject("hello")
1775            .build_outbound()
1776            .expect("message should validate");
1777
1778        let outbound_json =
1779            serde_json::to_string(&outbound).expect("OutboundMessage should serialize");
1780        let message_json =
1781            serde_json::to_string(outbound.as_message()).expect("Message should serialize");
1782        assert_eq!(
1783            outbound_json, message_json,
1784            "OutboundMessage serde representation must match its inner Message"
1785        );
1786
1787        let roundtripped: OutboundMessage =
1788            serde_json::from_str(&outbound_json).expect("OutboundMessage should deserialize");
1789        assert_eq!(roundtripped, outbound);
1790    }
1791
1792    #[cfg(feature = "serde")]
1793    #[test]
1794    fn outbound_message_deserialize_rejects_invalid_payload() {
1795        // A Message that lacks `from` round-trips through Message::serde
1796        // but must be rejected on the outbound deserialize path.
1797        let invalid_message = Message {
1798            from: None,
1799            sender: None,
1800            to: vec![Address::Mailbox(mailbox("bob@example.com"))],
1801            cc: Vec::new(),
1802            bcc: Vec::new(),
1803            reply_to: Vec::new(),
1804            subject: None,
1805            date: None,
1806            message_id: None,
1807            headers: Vec::new(),
1808            body: Body::text("hi"),
1809            attachments: Vec::new(),
1810        };
1811        let json = serde_json::to_string(&invalid_message).expect("Message should serialize");
1812        assert!(serde_json::from_str::<OutboundMessage>(&json).is_err());
1813    }
1814
1815    #[cfg(feature = "serde")]
1816    #[test]
1817    fn outbound_message_deserialize_defaults_omitted_optional_lists() {
1818        let value = serde_json::json!({
1819            "from": {"type": "mailbox", "name": null, "email": "alice@example.com"},
1820            "to": [{"type": "mailbox", "name": null, "email": "bob@example.com"}],
1821            "body": {"type": "text", "text": "hi"}
1822        });
1823
1824        let outbound: OutboundMessage = serde_json::from_value(value)
1825            .expect("defaulted collection fields should be optional on the wire");
1826
1827        assert_eq!(outbound.as_message().to().len(), 1);
1828        assert!(outbound.as_message().cc().is_empty());
1829        assert!(outbound.as_message().bcc().is_empty());
1830        assert!(outbound.as_message().reply_to().is_empty());
1831        assert!(outbound.as_message().headers().is_empty());
1832        assert!(outbound.as_message().attachments().is_empty());
1833    }
1834
1835    #[test]
1836    fn builder_constructs_valid_message() {
1837        let date = OffsetDateTime::parse("Fri, 06 Mar 2026 12:00:00 +0000", &Rfc2822)
1838            .expect("date should parse");
1839        let message_id = "<test@example.com>"
1840            .parse::<MessageId>()
1841            .expect("message id should parse");
1842
1843        let message = Message::builder(Body::text("Hello"))
1844            .from_mailbox(mailbox("Mary Smith <mary@x.test>"))
1845            .add_to(address("jdoe@one.test"))
1846            .subject("Greeting")
1847            .date(date)
1848            .message_id(message_id.clone())
1849            .add_header(Header::new("X-Test", "demo").expect("header should validate"))
1850            .build()
1851            .expect("message should validate");
1852
1853        assert!(message.from_mailbox().is_some(), "from should be set");
1854        assert_eq!(message.to().len(), 1, "expected one recipient");
1855        assert_eq!(message.date(), Some(&date));
1856        assert_eq!(message.message_id(), Some(&message_id));
1857        assert_eq!(message.headers().len(), 1);
1858    }
1859
1860    #[test]
1861    fn derive_envelope_uses_sender_and_expands_groups() {
1862        let message = Message::builder(Body::text("Hello"))
1863            .from_mailbox(mailbox("from@example.com"))
1864            .sender(mailbox("sender@example.com"))
1865            .to(vec![address("Friends: a@example.com, b@example.com;")])
1866            .add_cc(address("c@example.com"))
1867            .build()
1868            .expect("message should validate");
1869
1870        let envelope = message.derive_envelope().expect("envelope should derive");
1871
1872        assert_eq!(
1873            envelope.mail_from().map(EmailAddress::as_str),
1874            Some("sender@example.com")
1875        );
1876        assert_eq!(
1877            envelope
1878                .rcpt_to()
1879                .iter()
1880                .map(EmailAddress::as_str)
1881                .collect::<Vec<_>>(),
1882            vec!["a@example.com", "b@example.com", "c@example.com"]
1883        );
1884    }
1885
1886    #[test]
1887    fn body_convenience_constructors_create_expected_variants() {
1888        assert_eq!(Body::text("hello"), Body::Text("hello".to_owned()));
1889        assert_eq!(
1890            Body::html("<p>hello</p>"),
1891            Body::Html("<p>hello</p>".to_owned())
1892        );
1893        assert_eq!(
1894            Body::text_and_html("hello", "<p>hello</p>"),
1895            Body::TextAndHtml {
1896                text: "hello".to_owned(),
1897                html: "<p>hello</p>".to_owned(),
1898            }
1899        );
1900    }
1901
1902    #[test]
1903    fn attachment_reference_constructor_preserves_reference() {
1904        let reference = AttachmentReference::new("550e8400-e29b-41d4-a716-446655440000");
1905
1906        assert_eq!(reference.as_str(), "550e8400-e29b-41d4-a716-446655440000");
1907    }
1908
1909    #[test]
1910    fn attachment_reference_converts_from_strings() {
1911        let from_str = AttachmentReference::from("s3://bucket/key");
1912        let from_string = AttachmentReference::from(String::from("s3://bucket/key"));
1913
1914        assert_eq!(from_str, from_string);
1915        assert_eq!(from_str, AttachmentReference::new("s3://bucket/key"));
1916        assert_eq!(from_str.as_ref(), "s3://bucket/key");
1917    }
1918
1919    #[test]
1920    fn with_attachments_replaces_existing_attachments() {
1921        let message = Message::builder(Body::text("Hello"))
1922            .from_mailbox(mailbox("from@example.com"))
1923            .add_to(address("to@example.com"))
1924            .add_attachment(
1925                Attachment::bytes(
1926                    ContentType::try_from("text/plain").expect("content type should parse"),
1927                    b"old".to_vec(),
1928                )
1929                .with_filename("old.txt"),
1930            )
1931            .build()
1932            .expect("message should validate");
1933
1934        let updated = message.clone().with_attachments(vec![
1935            Attachment::bytes(
1936                ContentType::try_from("text/plain").expect("content type should parse"),
1937                b"new".to_vec(),
1938            )
1939            .with_filename("new.txt"),
1940        ]);
1941
1942        assert_eq!(message.attachments().len(), 1);
1943        assert_eq!(updated.attachments().len(), 1);
1944        assert_eq!(updated.attachments()[0].filename(), Some("new.txt"));
1945    }
1946}