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