Skip to main content

email_message/
message.rs

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