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