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#[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#[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 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#[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 #[default]
311 Attachment,
312 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 #[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 #[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
747#[non_exhaustive]
748pub struct OutboundMessage {
749 inner: Message,
751 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 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 pub fn new(message: Message) -> Result<Self, MessageValidationError> {
804 message.validate_basic()?;
805 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 #[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#[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
946fn 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
1000const 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 #[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 #[must_use]
1049 pub const fn builder(body: Body) -> MessageBuilder {
1050 MessageBuilder::new(body)
1051 }
1052
1053 #[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 #[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 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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
1408 pub fn add_attachment(mut self, attachment: Attachment) -> Self {
1409 self.message.attachments.push(attachment);
1410 self
1411 }
1412
1413 #[must_use]
1427 pub fn build_unchecked(self) -> Message {
1428 self.message
1429 }
1430
1431 pub fn build(self) -> Result<Message, MessageValidationError> {
1438 self.message.validate_basic()?;
1439 Ok(self.message)
1440 }
1441
1442 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 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 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}