1use crate::mime_types::ContentType;
8#[cfg(feature = "mime")]
9use crate::mime_types::MimePart;
10use crate::{Address, EmailAddress, Mailbox, MessageId};
11use time::OffsetDateTime;
12
13#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
15#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
16#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
17#[derive(Clone, Debug, PartialEq, Eq, Hash)]
18pub struct Envelope {
19 #[cfg_attr(
20 feature = "serde",
21 serde(default, skip_serializing_if = "Option::is_none")
22 )]
23 mail_from: Option<EmailAddress>,
24 #[cfg_attr(
25 feature = "serde",
26 serde(default, skip_serializing_if = "Vec::is_empty")
27 )]
28 rcpt_to: Vec<EmailAddress>,
29}
30
31impl Envelope {
32 #[must_use]
34 pub const fn new(mail_from: Option<EmailAddress>, rcpt_to: Vec<EmailAddress>) -> Self {
35 Self { mail_from, rcpt_to }
36 }
37
38 #[must_use]
40 pub const fn mail_from(&self) -> Option<&EmailAddress> {
41 self.mail_from.as_ref()
42 }
43
44 #[must_use]
46 pub fn rcpt_to(&self) -> &[EmailAddress] {
47 self.rcpt_to.as_slice()
48 }
49}
50
51#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
53#[derive(Clone, Debug, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct Header {
56 name: String,
57 value: String,
58}
59
60#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
62#[non_exhaustive]
63pub enum HeaderValidationError {
64 #[error("header name cannot be empty")]
66 EmptyName,
67 #[error("header name `{name}` is invalid")]
69 InvalidName {
70 name: String,
72 },
73 #[error("header `{name}` contains raw newline characters")]
75 ValueContainsRawNewline {
76 name: String,
78 },
79 #[error("header `{name}` contains invalid control characters")]
81 ValueContainsControlCharacter {
82 name: String,
84 },
85}
86
87impl Header {
88 #[must_use]
90 pub fn name(&self) -> &str {
91 &self.name
92 }
93
94 #[must_use]
96 pub fn value(&self) -> &str {
97 &self.value
98 }
99
100 pub fn new(
122 name: impl Into<String>,
123 value: impl Into<String>,
124 ) -> Result<Self, HeaderValidationError> {
125 let name = name.into();
126 let value = value.into();
127 validate_header(&name, &value)?;
128 Ok(Self { name, value })
129 }
130}
131
132fn validate_header(name: &str, value: &str) -> Result<(), HeaderValidationError> {
133 if name.is_empty() {
134 return Err(HeaderValidationError::EmptyName);
135 }
136 if !name.bytes().all(is_header_name_byte) {
137 return Err(HeaderValidationError::InvalidName {
138 name: name.to_owned(),
139 });
140 }
141 if value.contains(['\r', '\n']) {
142 return Err(HeaderValidationError::ValueContainsRawNewline {
143 name: name.to_owned(),
144 });
145 }
146 if value
147 .bytes()
148 .any(|byte| byte.is_ascii_control() && byte != b'\t')
149 {
150 return Err(HeaderValidationError::ValueContainsControlCharacter {
151 name: name.to_owned(),
152 });
153 }
154 Ok(())
155}
156
157const fn is_header_name_byte(byte: u8) -> bool {
158 matches!(byte, b'!'..=b'9' | b';'..=b'~')
159}
160
161#[cfg(feature = "serde")]
162impl serde::Serialize for Header {
163 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
164 where
165 S: serde::Serializer,
166 {
167 use serde::ser::SerializeStruct;
168
169 let mut value = serializer.serialize_struct("Header", 2)?;
170 value.serialize_field("name", self.name())?;
171 value.serialize_field("value", self.value())?;
172 value.end()
173 }
174}
175
176#[cfg(feature = "serde")]
177impl<'de> serde::Deserialize<'de> for Header {
178 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
179 where
180 D: serde::Deserializer<'de>,
181 {
182 #[derive(serde::Deserialize)]
183 struct RawHeader {
184 name: String,
185 value: String,
186 }
187
188 let raw = RawHeader::deserialize(deserializer)?;
189 Self::new(raw.name, raw.value).map_err(serde::de::Error::custom)
190 }
191}
192
193#[cfg(feature = "arbitrary")]
194impl<'a> arbitrary::Arbitrary<'a> for Header {
195 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
196 let suffix = u32::arbitrary(u)?;
197 let value = u32::arbitrary(u)?;
198 Self::new(format!("X-Arbitrary-{suffix}"), value.to_string())
199 .map_err(|_| arbitrary::Error::IncorrectFormat)
200 }
201}
202
203#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
209#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
210#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
211#[derive(Clone, Debug, PartialEq, Eq, Hash)]
212#[non_exhaustive]
213pub struct AttachmentReference {
214 reference: String,
215}
216
217impl AttachmentReference {
218 #[must_use]
220 pub fn new(reference: impl Into<String>) -> Self {
221 Self {
222 reference: reference.into(),
223 }
224 }
225
226 #[must_use]
228 pub fn as_str(&self) -> &str {
229 self.reference.as_str()
230 }
231}
232
233#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
235#[derive(Clone, Debug, PartialEq, Eq)]
236#[non_exhaustive]
237pub enum AttachmentBody {
238 Bytes(Vec<u8>),
240 Reference(AttachmentReference),
242}
243
244#[cfg(feature = "serde")]
245impl serde::Serialize for AttachmentBody {
246 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
247 where
248 S: serde::Serializer,
249 {
250 use base64::Engine as _;
251 use serde::ser::SerializeStruct as _;
252
253 match self {
254 Self::Bytes(bytes) => {
255 let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
256 let mut value = serializer.serialize_struct("AttachmentBody", 2)?;
257 value.serialize_field("type", "bytes")?;
258 value.serialize_field("bytes", &encoded)?;
259 value.end()
260 }
261 Self::Reference(reference) => {
262 let mut value = serializer.serialize_struct("AttachmentBody", 2)?;
263 value.serialize_field("type", "reference")?;
264 value.serialize_field("reference", reference.as_str())?;
265 value.end()
266 }
267 }
268 }
269}
270
271#[cfg(feature = "serde")]
272impl<'de> serde::Deserialize<'de> for AttachmentBody {
273 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
274 where
275 D: serde::Deserializer<'de>,
276 {
277 use base64::Engine as _;
278
279 #[derive(serde::Deserialize)]
280 #[serde(tag = "type", rename_all = "snake_case")]
281 enum RawAttachmentBody {
282 Bytes { bytes: String },
283 Reference { reference: String },
284 }
285
286 Ok(match RawAttachmentBody::deserialize(deserializer)? {
287 RawAttachmentBody::Bytes { bytes } => {
288 let decoded = base64::engine::general_purpose::STANDARD
289 .decode(bytes.as_bytes())
290 .map_err(|err| {
291 serde::de::Error::custom(format!("invalid base64 attachment bytes: {err}"))
292 })?;
293 Self::Bytes(decoded)
294 }
295 RawAttachmentBody::Reference { reference } => {
296 Self::Reference(AttachmentReference::new(reference))
297 }
298 })
299 }
300}
301
302#[cfg(feature = "schemars")]
303impl schemars::JsonSchema for AttachmentBody {
304 fn schema_name() -> std::borrow::Cow<'static, str> {
305 "AttachmentBody".into()
306 }
307
308 fn schema_id() -> std::borrow::Cow<'static, str> {
309 concat!(module_path!(), "::AttachmentBody").into()
310 }
311
312 fn json_schema(_generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
313 schemars::json_schema!({
314 "oneOf": [
315 {
316 "type": "object",
317 "properties": {
318 "type": {"const": "bytes"},
319 "bytes": {
320 "type": "string",
321 "contentEncoding": "base64",
322 "description": "Base64-encoded attachment bytes (RFC 4648, with padding)"
323 }
324 },
325 "required": ["type", "bytes"]
326 },
327 {
328 "type": "object",
329 "properties": {
330 "type": {"const": "reference"},
331 "reference": {"type": "string"}
332 },
333 "required": ["type", "reference"]
334 }
335 ]
336 })
337 }
338}
339
340#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
342#[cfg_attr(feature = "serde", serde(rename_all = "lowercase"))]
343#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
344#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
345#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
346#[non_exhaustive]
347pub enum Disposition {
348 #[default]
350 Attachment,
351 Inline,
353}
354
355impl Disposition {
356 #[must_use]
358 pub const fn is_inline(&self) -> bool {
359 matches!(self, Self::Inline)
360 }
361}
362
363#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
365#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
366#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
367#[derive(Clone, Debug, PartialEq, Eq)]
368#[non_exhaustive]
369pub struct Attachment {
370 #[cfg_attr(
371 feature = "serde",
372 serde(default, skip_serializing_if = "Option::is_none")
373 )]
374 filename: Option<String>,
375 #[cfg_attr(
376 feature = "schemars",
377 schemars(with = "String", description = "MIME content type")
378 )]
379 content_type: ContentType,
380 #[cfg_attr(
381 feature = "serde",
382 serde(default, skip_serializing_if = "Option::is_none")
383 )]
384 content_id: Option<String>,
385 #[cfg_attr(
389 feature = "serde",
390 serde(
391 default,
392 alias = "inline",
393 deserialize_with = "deserialize_disposition_compat"
394 )
395 )]
396 #[cfg_attr(feature = "schemars", schemars(default))]
397 disposition: Disposition,
398 body: AttachmentBody,
399}
400
401#[cfg(feature = "serde")]
402fn deserialize_disposition_compat<'de, D>(deserializer: D) -> Result<Disposition, D::Error>
403where
404 D: serde::Deserializer<'de>,
405{
406 use serde::Deserialize as _;
407
408 #[derive(serde::Deserialize)]
409 #[serde(untagged)]
410 enum Compat {
411 Bool(bool),
412 Tag(Disposition),
413 }
414 Ok(match Compat::deserialize(deserializer)? {
415 Compat::Bool(true) => Disposition::Inline,
416 Compat::Bool(false) => Disposition::Attachment,
417 Compat::Tag(d) => d,
418 })
419}
420
421impl Attachment {
422 #[must_use]
424 pub const fn new(content_type: ContentType, body: AttachmentBody) -> Self {
425 Self {
426 filename: None,
427 content_type,
428 content_id: None,
429 disposition: Disposition::Attachment,
430 body,
431 }
432 }
433
434 #[must_use]
436 pub fn bytes(content_type: ContentType, bytes: impl Into<Vec<u8>>) -> Self {
437 Self::new(content_type, AttachmentBody::Bytes(bytes.into()))
438 }
439
440 #[must_use]
442 pub const fn reference(content_type: ContentType, reference: AttachmentReference) -> Self {
443 Self::new(content_type, AttachmentBody::Reference(reference))
444 }
445
446 #[must_use]
448 pub fn filename(&self) -> Option<&str> {
449 self.filename.as_deref()
450 }
451
452 #[must_use]
454 pub const fn content_type(&self) -> &ContentType {
455 &self.content_type
456 }
457
458 #[must_use]
460 pub fn content_id(&self) -> Option<&str> {
461 self.content_id.as_deref()
462 }
463
464 #[must_use]
466 pub const fn disposition(&self) -> Disposition {
467 self.disposition
468 }
469
470 #[must_use]
472 pub const fn is_inline(&self) -> bool {
473 self.disposition.is_inline()
474 }
475
476 #[must_use]
478 pub const fn body(&self) -> &AttachmentBody {
479 &self.body
480 }
481
482 pub fn set_body(&mut self, body: AttachmentBody) {
484 self.body = body;
485 }
486
487 #[must_use]
489 pub fn with_body(mut self, body: AttachmentBody) -> Self {
490 self.body = body;
491 self
492 }
493
494 #[must_use]
496 pub fn with_filename(mut self, filename: impl Into<String>) -> Self {
497 self.filename = Some(filename.into());
498 self
499 }
500
501 #[must_use]
503 pub fn with_content_id(mut self, content_id: impl Into<String>) -> Self {
504 self.content_id = Some(content_id.into());
505 self
506 }
507
508 #[must_use]
510 pub const fn with_disposition(mut self, disposition: Disposition) -> Self {
511 self.disposition = disposition;
512 self
513 }
514}
515
516#[cfg_attr(feature = "arbitrary", derive(arbitrary::Arbitrary))]
535#[derive(Clone, Debug, PartialEq, Eq)]
536#[non_exhaustive]
537pub enum Body {
538 Text(String),
540 Html(String),
542 TextAndHtml {
544 text: String,
546 html: String,
548 },
549 #[cfg(feature = "mime")]
551 Mime(MimePart),
552}
553
554#[cfg(feature = "serde")]
555impl serde::Serialize for Body {
556 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
557 where
558 S: serde::Serializer,
559 {
560 use serde::ser::SerializeStruct as _;
561
562 match self {
563 Self::Text(text) => {
564 let mut value = serializer.serialize_struct("Body", 2)?;
565 value.serialize_field("type", "text")?;
566 value.serialize_field("text", text)?;
567 value.end()
568 }
569 Self::Html(html) => {
570 let mut value = serializer.serialize_struct("Body", 2)?;
571 value.serialize_field("type", "html")?;
572 value.serialize_field("html", html)?;
573 value.end()
574 }
575 Self::TextAndHtml { text, html } => {
576 let mut value = serializer.serialize_struct("Body", 3)?;
577 value.serialize_field("type", "text_and_html")?;
578 value.serialize_field("text", text)?;
579 value.serialize_field("html", html)?;
580 value.end()
581 }
582 #[cfg(feature = "mime")]
583 Self::Mime(part) => {
584 let mut value = serializer.serialize_struct("Body", 2)?;
585 value.serialize_field("type", "mime")?;
586 value.serialize_field("part", part)?;
587 value.end()
588 }
589 }
590 }
591}
592
593#[cfg(feature = "serde")]
594impl<'de> serde::Deserialize<'de> for Body {
595 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
596 where
597 D: serde::Deserializer<'de>,
598 {
599 #[derive(serde::Deserialize)]
600 #[serde(tag = "type", rename_all = "snake_case")]
601 enum RawBody {
602 Text {
603 text: String,
604 },
605 Html {
606 html: String,
607 },
608 TextAndHtml {
609 text: String,
610 html: String,
611 },
612 #[cfg(feature = "mime")]
613 Mime {
614 part: MimePart,
615 },
616 }
617
618 Ok(match RawBody::deserialize(deserializer)? {
619 RawBody::Text { text } => Self::Text(text),
620 RawBody::Html { html } => Self::Html(html),
621 RawBody::TextAndHtml { text, html } => Self::TextAndHtml { text, html },
622 #[cfg(feature = "mime")]
623 RawBody::Mime { part } => Self::Mime(part),
624 })
625 }
626}
627
628#[cfg(feature = "schemars")]
629impl schemars::JsonSchema for Body {
630 fn schema_name() -> std::borrow::Cow<'static, str> {
631 "Body".into()
632 }
633
634 fn schema_id() -> std::borrow::Cow<'static, str> {
635 concat!(module_path!(), "::Body").into()
636 }
637
638 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
639 #[cfg(not(feature = "mime"))]
640 let _ = generator;
641
642 let variants = vec![
643 schemars::json_schema!({
644 "type": "object",
645 "properties": {
646 "type": {"const": "text"},
647 "text": {"type": "string"}
648 },
649 "required": ["type", "text"]
650 }),
651 schemars::json_schema!({
652 "type": "object",
653 "properties": {
654 "type": {"const": "html"},
655 "html": {"type": "string"}
656 },
657 "required": ["type", "html"]
658 }),
659 schemars::json_schema!({
660 "type": "object",
661 "properties": {
662 "type": {"const": "text_and_html"},
663 "text": {"type": "string"},
664 "html": {"type": "string"}
665 },
666 "required": ["type", "text", "html"]
667 }),
668 ];
669
670 #[cfg(feature = "mime")]
671 let variants = {
672 let mut variants = variants;
673 let part = generator.subschema_for::<MimePart>();
674 variants.push(schemars::json_schema!({
675 "type": "object",
676 "properties": {
677 "type": {"const": "mime"},
678 "part": part
679 },
680 "required": ["type", "part"]
681 }));
682 variants
683 };
684
685 schemars::json_schema!({"oneOf": variants})
686 }
687}
688
689impl Body {
690 #[must_use]
692 pub fn text(value: impl Into<String>) -> Self {
693 Self::Text(value.into())
694 }
695
696 #[must_use]
698 pub fn html(value: impl Into<String>) -> Self {
699 Self::Html(value.into())
700 }
701
702 #[must_use]
704 pub fn text_and_html(text: impl Into<String>, html: impl Into<String>) -> Self {
705 Self::TextAndHtml {
706 text: text.into(),
707 html: html.into(),
708 }
709 }
710}
711
712#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
733#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
734#[derive(Clone, Debug, PartialEq, Eq)]
735#[allow(clippy::struct_field_names)]
736#[non_exhaustive]
737pub struct Message {
738 #[cfg_attr(
739 feature = "serde",
740 serde(default, skip_serializing_if = "Option::is_none")
741 )]
742 from: Option<Mailbox>,
743 #[cfg_attr(
744 feature = "serde",
745 serde(default, skip_serializing_if = "Option::is_none")
746 )]
747 sender: Option<Mailbox>,
748 #[cfg_attr(
749 feature = "serde",
750 serde(default, skip_serializing_if = "Vec::is_empty")
751 )]
752 #[cfg_attr(feature = "schemars", schemars(default))]
753 to: Vec<Address>,
754 #[cfg_attr(
755 feature = "serde",
756 serde(default, skip_serializing_if = "Vec::is_empty")
757 )]
758 #[cfg_attr(feature = "schemars", schemars(default))]
759 cc: Vec<Address>,
760 #[cfg_attr(
761 feature = "serde",
762 serde(default, skip_serializing_if = "Vec::is_empty")
763 )]
764 #[cfg_attr(feature = "schemars", schemars(default))]
765 bcc: Vec<Address>,
766 #[cfg_attr(
767 feature = "serde",
768 serde(default, skip_serializing_if = "Vec::is_empty")
769 )]
770 #[cfg_attr(feature = "schemars", schemars(default))]
771 reply_to: Vec<Address>,
772 #[cfg_attr(
773 feature = "serde",
774 serde(default, skip_serializing_if = "Option::is_none")
775 )]
776 subject: Option<String>,
777 #[cfg_attr(
778 feature = "serde",
779 serde(default, skip_serializing_if = "Option::is_none")
780 )]
781 #[cfg_attr(
782 feature = "schemars",
783 schemars(with = "Option<String>", description = "RFC 2822 date-time")
784 )]
785 date: Option<OffsetDateTime>,
786 #[cfg_attr(
787 feature = "serde",
788 serde(default, skip_serializing_if = "Option::is_none")
789 )]
790 message_id: Option<MessageId>,
791 #[cfg_attr(
792 feature = "serde",
793 serde(default, skip_serializing_if = "Vec::is_empty")
794 )]
795 #[cfg_attr(feature = "schemars", schemars(default))]
796 headers: Vec<Header>,
797 body: Body,
798 #[cfg_attr(
799 feature = "serde",
800 serde(default, skip_serializing_if = "Vec::is_empty")
801 )]
802 #[cfg_attr(feature = "schemars", schemars(default))]
803 attachments: Vec<Attachment>,
804}
805
806#[derive(Clone, Debug, PartialEq, Eq)]
812#[non_exhaustive]
813pub struct OutboundMessage {
814 inner: Message,
816 from: Mailbox,
821}
822
823#[cfg(feature = "serde")]
824impl serde::Serialize for OutboundMessage {
825 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
826 where
827 S: serde::Serializer,
828 {
829 self.inner.serialize(serializer)
832 }
833}
834
835#[cfg(feature = "serde")]
836impl<'de> serde::Deserialize<'de> for OutboundMessage {
837 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
838 where
839 D: serde::Deserializer<'de>,
840 {
841 let message = Message::deserialize(deserializer)?;
842 Self::new(message).map_err(serde::de::Error::custom)
843 }
844}
845
846#[cfg(feature = "schemars")]
847impl schemars::JsonSchema for OutboundMessage {
848 fn schema_name() -> std::borrow::Cow<'static, str> {
849 <Message as schemars::JsonSchema>::schema_name()
850 }
851
852 fn schema_id() -> std::borrow::Cow<'static, str> {
853 <Message as schemars::JsonSchema>::schema_id()
854 }
855
856 fn json_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
857 <Message as schemars::JsonSchema>::json_schema(generator)
858 }
859}
860
861impl OutboundMessage {
862 pub fn new(message: Message) -> Result<Self, MessageValidationError> {
869 message.validate_basic()?;
870 let from = message
875 .from
876 .clone()
877 .ok_or(MessageValidationError::MissingFrom)?;
878 Ok(Self {
879 inner: message,
880 from,
881 })
882 }
883
884 #[must_use]
886 pub const fn as_message(&self) -> &Message {
887 &self.inner
888 }
889
890 #[must_use]
892 pub fn into_message(self) -> Message {
893 self.inner
894 }
895
896 #[must_use]
902 pub const fn from_mailbox(&self) -> &Mailbox {
903 &self.from
904 }
905}
906
907impl TryFrom<Message> for OutboundMessage {
908 type Error = MessageValidationError;
909
910 fn try_from(value: Message) -> Result<Self, Self::Error> {
911 Self::new(value)
912 }
913}
914
915impl From<OutboundMessage> for Message {
916 fn from(value: OutboundMessage) -> Self {
917 value.inner
918 }
919}
920
921#[cfg(feature = "arbitrary")]
922impl<'a> arbitrary::Arbitrary<'a> for Message {
923 fn arbitrary(u: &mut arbitrary::Unstructured<'a>) -> arbitrary::Result<Self> {
924 let has_date = bool::arbitrary(u)?;
925 let date = if has_date {
926 let seconds = i64::arbitrary(u)?;
927 Some(OffsetDateTime::from_unix_timestamp(seconds).unwrap_or(OffsetDateTime::UNIX_EPOCH))
928 } else {
929 None
930 };
931
932 Ok(Self {
933 from: Option::<Mailbox>::arbitrary(u)?,
934 sender: Option::<Mailbox>::arbitrary(u)?,
935 to: Vec::<Address>::arbitrary(u)?,
936 cc: Vec::<Address>::arbitrary(u)?,
937 bcc: Vec::<Address>::arbitrary(u)?,
938 reply_to: Vec::<Address>::arbitrary(u)?,
939 subject: Option::<String>::arbitrary(u)?,
940 date,
941 message_id: Option::<MessageId>::arbitrary(u)?,
942 headers: Vec::<Header>::arbitrary(u)?,
943 body: Body::arbitrary(u)?,
944 attachments: Vec::<Attachment>::arbitrary(u)?,
945 })
946 }
947}
948
949#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
983#[non_exhaustive]
984pub enum MessageValidationError {
985 #[error("missing From header")]
987 MissingFrom,
988 #[error("sender header cannot appear without from")]
990 SenderWithoutFrom,
991 #[error("no recipients in To/Cc/Bcc")]
993 MissingRecipients,
994 #[error(
996 "custom header `{name}` collides with a structured field; use the typed setter (Subject, Date, Message-ID, From, ...) instead"
997 )]
998 #[non_exhaustive]
999 ReservedHeaderName {
1000 name: String,
1002 },
1003 #[error("subject contains raw CR, LF, or non-tab control characters")]
1005 SubjectContainsInvalidChars,
1006 #[error(
1008 "mailbox display name in `{location}` contains raw CR, LF, NUL, or non-tab control characters"
1009 )]
1010 #[non_exhaustive]
1011 MailboxDisplayNameContainsInvalidChars {
1012 location: &'static str,
1014 },
1015 #[error(
1017 "attachment metadata field `{field}` contains raw CR, LF, NUL, or non-tab control characters"
1018 )]
1019 #[non_exhaustive]
1020 AttachmentMetadataContainsInvalidChars {
1021 field: &'static str,
1023 },
1024}
1025
1026fn contains_header_unsafe_chars(value: &str) -> bool {
1027 value
1028 .bytes()
1029 .any(|byte| byte == b'\r' || byte == b'\n' || (byte != b'\t' && byte.is_ascii_control()))
1030}
1031
1032fn validate_address_mailboxes(
1037 addresses: &[Address],
1038 location: &'static str,
1039) -> Result<(), MessageValidationError> {
1040 for address in addresses {
1041 match address {
1042 Address::Mailbox(mailbox) => {
1043 if let Some(name) = mailbox.name()
1044 && contains_header_unsafe_chars(name)
1045 {
1046 return Err(
1047 MessageValidationError::MailboxDisplayNameContainsInvalidChars { location },
1048 );
1049 }
1050 }
1051 Address::Group(group) => {
1052 if contains_header_unsafe_chars(group.name()) {
1053 return Err(
1054 MessageValidationError::MailboxDisplayNameContainsInvalidChars { location },
1055 );
1056 }
1057 for member in group.members() {
1058 if let Some(name) = member.name()
1059 && contains_header_unsafe_chars(name)
1060 {
1061 return Err(
1062 MessageValidationError::MailboxDisplayNameContainsInvalidChars {
1063 location,
1064 },
1065 );
1066 }
1067 }
1068 }
1069 }
1070 }
1071 Ok(())
1072}
1073
1074fn validate_mailbox_display_name(
1075 mailbox: &Mailbox,
1076 location: &'static str,
1077) -> Result<(), MessageValidationError> {
1078 if let Some(name) = mailbox.name()
1079 && contains_header_unsafe_chars(name)
1080 {
1081 return Err(MessageValidationError::MailboxDisplayNameContainsInvalidChars { location });
1082 }
1083 Ok(())
1084}
1085
1086const RESERVED_HEADER_NAMES: &[&str] = &[
1096 "from",
1097 "sender",
1098 "reply-to",
1099 "to",
1100 "cc",
1101 "bcc",
1102 "date",
1103 "subject",
1104 "message-id",
1105];
1106
1107fn is_reserved_header_name(name: &str) -> bool {
1108 RESERVED_HEADER_NAMES
1109 .iter()
1110 .any(|reserved| name.eq_ignore_ascii_case(reserved))
1111}
1112
1113impl Message {
1114 #[must_use]
1116 pub const fn new(from: Mailbox, to: Vec<Address>, body: Body) -> Self {
1117 Self {
1118 from: Some(from),
1119 sender: None,
1120 to,
1121 cc: Vec::new(),
1122 bcc: Vec::new(),
1123 reply_to: Vec::new(),
1124 subject: None,
1125 date: None,
1126 message_id: None,
1127 headers: Vec::new(),
1128 body,
1129 attachments: Vec::new(),
1130 }
1131 }
1132
1133 #[must_use]
1135 pub const fn builder(body: Body) -> MessageBuilder {
1136 MessageBuilder::new(body)
1137 }
1138
1139 #[must_use]
1145 pub const fn from_mailbox(&self) -> Option<&Mailbox> {
1146 self.from.as_ref()
1147 }
1148
1149 #[must_use]
1150 pub const fn sender(&self) -> Option<&Mailbox> {
1152 self.sender.as_ref()
1153 }
1154
1155 #[must_use]
1156 pub fn to(&self) -> &[Address] {
1158 self.to.as_slice()
1159 }
1160
1161 #[must_use]
1162 pub fn cc(&self) -> &[Address] {
1164 self.cc.as_slice()
1165 }
1166
1167 #[must_use]
1168 pub fn bcc(&self) -> &[Address] {
1170 self.bcc.as_slice()
1171 }
1172
1173 #[must_use]
1174 pub fn reply_to(&self) -> &[Address] {
1176 self.reply_to.as_slice()
1177 }
1178
1179 #[must_use]
1180 pub fn subject(&self) -> Option<&str> {
1182 self.subject.as_deref()
1183 }
1184
1185 #[must_use]
1186 pub const fn date(&self) -> Option<&OffsetDateTime> {
1188 self.date.as_ref()
1189 }
1190
1191 #[must_use]
1192 pub const fn message_id(&self) -> Option<&MessageId> {
1194 self.message_id.as_ref()
1195 }
1196
1197 #[must_use]
1198 pub fn headers(&self) -> &[Header] {
1200 self.headers.as_slice()
1201 }
1202
1203 #[must_use]
1204 pub const fn body(&self) -> &Body {
1206 &self.body
1207 }
1208
1209 #[must_use]
1210 pub fn attachments(&self) -> &[Attachment] {
1212 self.attachments.as_slice()
1213 }
1214
1215 #[must_use]
1216 pub fn with_attachments<I>(mut self, attachments: I) -> Self
1218 where
1219 I: IntoIterator<Item = Attachment>,
1220 {
1221 self.attachments = attachments.into_iter().collect();
1222 self
1223 }
1224
1225 #[must_use]
1227 pub fn into_attachments(mut self) -> (Self, Vec<Attachment>) {
1228 let attachments = std::mem::take(&mut self.attachments);
1229 (self, attachments)
1230 }
1231
1232 pub fn validate_basic(&self) -> Result<(), MessageValidationError> {
1257 if self.sender.is_some() && self.from.is_none() {
1258 return Err(MessageValidationError::SenderWithoutFrom);
1259 }
1260
1261 if self.from.is_none() {
1262 return Err(MessageValidationError::MissingFrom);
1263 }
1264
1265 if self.to.is_empty() && self.cc.is_empty() && self.bcc.is_empty() {
1266 return Err(MessageValidationError::MissingRecipients);
1267 }
1268
1269 if let Some(subject) = self.subject.as_deref()
1270 && contains_header_unsafe_chars(subject)
1271 {
1272 return Err(MessageValidationError::SubjectContainsInvalidChars);
1273 }
1274
1275 for header in &self.headers {
1276 if is_reserved_header_name(header.name()) {
1277 return Err(MessageValidationError::ReservedHeaderName {
1278 name: header.name().to_owned(),
1279 });
1280 }
1281 }
1282
1283 if let Some(from) = self.from.as_ref() {
1284 validate_mailbox_display_name(from, "from")?;
1285 }
1286 if let Some(sender) = self.sender.as_ref() {
1287 validate_mailbox_display_name(sender, "sender")?;
1288 }
1289 validate_address_mailboxes(&self.to, "to")?;
1290 validate_address_mailboxes(&self.cc, "cc")?;
1291 validate_address_mailboxes(&self.bcc, "bcc")?;
1292 validate_address_mailboxes(&self.reply_to, "reply-to")?;
1293
1294 for attachment in &self.attachments {
1295 if let Some(filename) = attachment.filename()
1296 && contains_header_unsafe_chars(filename)
1297 {
1298 return Err(
1299 MessageValidationError::AttachmentMetadataContainsInvalidChars {
1300 field: "filename",
1301 },
1302 );
1303 }
1304 if let Some(content_id) = attachment.content_id()
1305 && contains_header_unsafe_chars(content_id)
1306 {
1307 return Err(
1308 MessageValidationError::AttachmentMetadataContainsInvalidChars {
1309 field: "content-id",
1310 },
1311 );
1312 }
1313 }
1314
1315 Ok(())
1316 }
1317
1318 pub fn derive_envelope(&self) -> Result<Envelope, MessageValidationError> {
1325 self.validate_basic()?;
1326
1327 let mail_from = self
1328 .sender
1329 .as_ref()
1330 .or(self.from.as_ref())
1331 .map(|mailbox| mailbox.email().clone());
1332
1333 let mut rcpt_to = Vec::new();
1334 extend_recipient_emails(&mut rcpt_to, &self.to);
1335 extend_recipient_emails(&mut rcpt_to, &self.cc);
1336 extend_recipient_emails(&mut rcpt_to, &self.bcc);
1337
1338 Ok(Envelope::new(mail_from, rcpt_to))
1339 }
1340}
1341
1342fn extend_recipient_emails(out: &mut Vec<EmailAddress>, addresses: &[Address]) {
1343 for address in addresses {
1344 out.extend(address.mailboxes().map(|mailbox| mailbox.email().clone()));
1345 }
1346}
1347
1348#[derive(Clone, Debug, PartialEq, Eq)]
1350#[non_exhaustive]
1351pub struct MessageBuilder {
1352 message: Message,
1353}
1354
1355impl MessageBuilder {
1356 #[must_use]
1358 pub const fn new(body: Body) -> Self {
1359 Self {
1360 message: Message {
1361 from: None,
1362 sender: None,
1363 to: Vec::new(),
1364 cc: Vec::new(),
1365 bcc: Vec::new(),
1366 reply_to: Vec::new(),
1367 subject: None,
1368 date: None,
1369 message_id: None,
1370 headers: Vec::new(),
1371 body,
1372 attachments: Vec::new(),
1373 },
1374 }
1375 }
1376
1377 #[must_use]
1382 pub fn from_mailbox(mut self, from: Mailbox) -> Self {
1383 self.message.from = Some(from);
1384 self
1385 }
1386
1387 #[must_use]
1389 pub fn sender(mut self, sender: Mailbox) -> Self {
1390 self.message.sender = Some(sender);
1391 self
1392 }
1393
1394 #[must_use]
1397 pub fn to<I>(mut self, to: I) -> Self
1398 where
1399 I: IntoIterator<Item = Address>,
1400 {
1401 self.message.to = to.into_iter().collect();
1402 self
1403 }
1404
1405 #[must_use]
1407 pub fn add_to(mut self, to: impl Into<Address>) -> Self {
1408 self.message.to.push(to.into());
1409 self
1410 }
1411
1412 #[must_use]
1414 pub fn cc<I>(mut self, cc: I) -> Self
1415 where
1416 I: IntoIterator<Item = Address>,
1417 {
1418 self.message.cc = cc.into_iter().collect();
1419 self
1420 }
1421
1422 #[must_use]
1424 pub fn add_cc(mut self, cc: impl Into<Address>) -> Self {
1425 self.message.cc.push(cc.into());
1426 self
1427 }
1428
1429 #[must_use]
1431 pub fn bcc<I>(mut self, bcc: I) -> Self
1432 where
1433 I: IntoIterator<Item = Address>,
1434 {
1435 self.message.bcc = bcc.into_iter().collect();
1436 self
1437 }
1438
1439 #[must_use]
1441 pub fn add_bcc(mut self, bcc: impl Into<Address>) -> Self {
1442 self.message.bcc.push(bcc.into());
1443 self
1444 }
1445
1446 #[must_use]
1448 pub fn reply_to<I>(mut self, reply_to: I) -> Self
1449 where
1450 I: IntoIterator<Item = Address>,
1451 {
1452 self.message.reply_to = reply_to.into_iter().collect();
1453 self
1454 }
1455
1456 #[must_use]
1458 pub fn add_reply_to(mut self, reply_to: impl Into<Address>) -> Self {
1459 self.message.reply_to.push(reply_to.into());
1460 self
1461 }
1462
1463 #[must_use]
1465 pub fn subject(mut self, subject: impl Into<String>) -> Self {
1466 self.message.subject = Some(subject.into());
1467 self
1468 }
1469
1470 #[must_use]
1472 pub const fn date(mut self, date: OffsetDateTime) -> Self {
1473 self.message.date = Some(date);
1474 self
1475 }
1476
1477 #[must_use]
1479 pub fn message_id(mut self, message_id: MessageId) -> Self {
1480 self.message.message_id = Some(message_id);
1481 self
1482 }
1483
1484 #[must_use]
1486 pub fn headers<I>(mut self, headers: I) -> Self
1487 where
1488 I: IntoIterator<Item = Header>,
1489 {
1490 self.message.headers = headers.into_iter().collect();
1491 self
1492 }
1493
1494 #[must_use]
1496 pub fn add_header(mut self, header: Header) -> Self {
1497 self.message.headers.push(header);
1498 self
1499 }
1500
1501 #[must_use]
1503 pub fn attachments<I>(mut self, attachments: I) -> Self
1504 where
1505 I: IntoIterator<Item = Attachment>,
1506 {
1507 self.message.attachments = attachments.into_iter().collect();
1508 self
1509 }
1510
1511 #[must_use]
1513 pub fn add_attachment(mut self, attachment: Attachment) -> Self {
1514 self.message.attachments.push(attachment);
1515 self
1516 }
1517
1518 #[must_use]
1532 pub fn build_unchecked(self) -> Message {
1533 self.message
1534 }
1535
1536 pub fn build(self) -> Result<Message, MessageValidationError> {
1543 self.message.validate_basic()?;
1544 Ok(self.message)
1545 }
1546
1547 pub fn build_outbound(self) -> Result<OutboundMessage, MessageValidationError> {
1554 OutboundMessage::new(self.message)
1555 }
1556}
1557
1558#[cfg(test)]
1559mod tests {
1560 use super::*;
1561 use time::format_description::well_known::Rfc2822;
1562
1563 fn mailbox(input: &str) -> Mailbox {
1564 input.parse::<Mailbox>().expect("mailbox should parse")
1565 }
1566
1567 fn address(input: &str) -> Address {
1568 input.parse::<Address>().expect("address should parse")
1569 }
1570
1571 #[test]
1572 fn validate_basic_reports_sender_without_from() {
1573 let error = Message::builder(Body::text("body"))
1574 .sender(mailbox("sender@example.com"))
1575 .add_to(address("to@example.com"))
1576 .build()
1577 .expect_err("message should be invalid");
1578
1579 assert_eq!(error, MessageValidationError::SenderWithoutFrom);
1580 }
1581
1582 #[test]
1583 fn validate_basic_rejects_reserved_header_names() {
1584 let error = Message::builder(Body::text("body"))
1585 .from_mailbox(mailbox("from@example.com"))
1586 .add_to(address("to@example.com"))
1587 .add_header(Header::new("Subject", "shadow").expect("header should validate"))
1588 .build()
1589 .expect_err("reserved header should be rejected");
1590
1591 assert!(matches!(
1592 error,
1593 MessageValidationError::ReservedHeaderName { ref name, .. } if name == "Subject"
1594 ));
1595 }
1596
1597 #[test]
1598 fn validate_basic_rejects_reserved_header_case_insensitively() {
1599 let error = Message::builder(Body::text("body"))
1600 .from_mailbox(mailbox("from@example.com"))
1601 .add_to(address("to@example.com"))
1602 .add_header(Header::new("MESSAGE-ID", "<x@y>").expect("header should validate"))
1603 .build()
1604 .expect_err("reserved header should be rejected");
1605
1606 assert!(matches!(
1607 error,
1608 MessageValidationError::ReservedHeaderName { ref name, .. } if name == "MESSAGE-ID"
1609 ));
1610 }
1611
1612 #[test]
1613 fn validate_basic_rejects_subject_with_crlf_injection() {
1614 let error = Message::builder(Body::text("body"))
1615 .from_mailbox(mailbox("from@example.com"))
1616 .add_to(address("to@example.com"))
1617 .subject("hi\r\nBcc: victim@example.com")
1618 .build()
1619 .expect_err("subject CRLF injection should be rejected");
1620
1621 assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1622 }
1623
1624 #[test]
1625 fn validate_basic_rejects_subject_with_bare_lf() {
1626 let error = Message::builder(Body::text("body"))
1627 .from_mailbox(mailbox("from@example.com"))
1628 .add_to(address("to@example.com"))
1629 .subject("hi\nbcc")
1630 .build()
1631 .expect_err("subject bare LF should be rejected");
1632
1633 assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1634 }
1635
1636 #[test]
1637 fn validate_basic_rejects_subject_with_control_char() {
1638 let error = Message::builder(Body::text("body"))
1639 .from_mailbox(mailbox("from@example.com"))
1640 .add_to(address("to@example.com"))
1641 .subject("hi\x07boss")
1642 .build()
1643 .expect_err("subject control char should be rejected");
1644
1645 assert_eq!(error, MessageValidationError::SubjectContainsInvalidChars);
1646 }
1647
1648 #[test]
1649 fn validate_basic_rejects_from_mailbox_with_crlf_in_display_name() {
1650 let email = "alice@example.com"
1651 .parse::<EmailAddress>()
1652 .expect("email parses");
1653 let hostile_from = Mailbox::from(("evil\r\nBcc: attacker@example.com".to_string(), email));
1654
1655 let error = Message::builder(Body::text("body"))
1656 .from_mailbox(hostile_from)
1657 .add_to(address("to@example.com"))
1658 .build()
1659 .expect_err("hostile From display name should be rejected");
1660
1661 assert!(matches!(
1662 error,
1663 MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1664 ));
1665 }
1666
1667 #[test]
1668 fn validate_basic_rejects_to_mailbox_with_lf_in_display_name() {
1669 let email = "victim@example.com"
1670 .parse::<EmailAddress>()
1671 .expect("email parses");
1672 let hostile_to = Address::Mailbox(Mailbox::from(("name\ninjection".to_string(), email)));
1673
1674 let error = Message::builder(Body::text("body"))
1675 .from_mailbox(mailbox("from@example.com"))
1676 .add_to(hostile_to)
1677 .build()
1678 .expect_err("hostile To display name should be rejected");
1679
1680 assert!(matches!(
1681 error,
1682 MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1683 ));
1684 }
1685
1686 #[test]
1687 fn validate_basic_rejects_group_member_with_nul_in_display_name() {
1688 let email = "member@example.com"
1694 .parse::<EmailAddress>()
1695 .expect("email parses");
1696 let hostile_cc = Address::Mailbox(Mailbox::from(("embed\0nul".to_string(), email)));
1697
1698 let error = Message::builder(Body::text("body"))
1699 .from_mailbox(mailbox("from@example.com"))
1700 .add_cc(hostile_cc)
1701 .build()
1702 .expect_err("hostile Cc display name should be rejected");
1703
1704 assert!(matches!(
1705 error,
1706 MessageValidationError::MailboxDisplayNameContainsInvalidChars { .. }
1707 ));
1708 }
1709
1710 #[test]
1711 fn validate_basic_accepts_mailbox_with_tab_in_display_name() {
1712 let email = "alice@example.com"
1713 .parse::<EmailAddress>()
1714 .expect("email parses");
1715 let from = Mailbox::from(("Alice\tBob".to_string(), email));
1716
1717 Message::builder(Body::text("body"))
1718 .from_mailbox(from)
1719 .add_to(address("to@example.com"))
1720 .build()
1721 .expect("tab in display name should be accepted");
1722 }
1723
1724 #[test]
1725 fn validate_basic_accepts_subject_with_tab() {
1726 let message = Message::builder(Body::text("body"))
1727 .from_mailbox(mailbox("from@example.com"))
1728 .add_to(address("to@example.com"))
1729 .subject("hi\tworld")
1730 .build()
1731 .expect("subject with tab should be accepted");
1732
1733 assert_eq!(message.subject(), Some("hi\tworld"));
1734 }
1735
1736 #[test]
1737 fn outbound_message_from_mailbox_returns_validated_field() {
1738 let outbound = Message::builder(Body::text("body"))
1739 .from_mailbox(mailbox("alice@example.com"))
1740 .add_to(address("bob@example.com"))
1741 .build_outbound()
1742 .expect("message should validate");
1743
1744 assert_eq!(
1745 outbound.from_mailbox().email().as_str(),
1746 "alice@example.com"
1747 );
1748 }
1749
1750 #[cfg(feature = "serde")]
1751 #[test]
1752 fn outbound_message_serde_format_matches_message() {
1753 let outbound = Message::builder(Body::text("body"))
1754 .from_mailbox(mailbox("alice@example.com"))
1755 .add_to(address("bob@example.com"))
1756 .subject("hello")
1757 .build_outbound()
1758 .expect("message should validate");
1759
1760 let outbound_json =
1761 serde_json::to_string(&outbound).expect("OutboundMessage should serialize");
1762 let message_json =
1763 serde_json::to_string(outbound.as_message()).expect("Message should serialize");
1764 assert_eq!(
1765 outbound_json, message_json,
1766 "OutboundMessage serde representation must match its inner Message"
1767 );
1768
1769 let roundtripped: OutboundMessage =
1770 serde_json::from_str(&outbound_json).expect("OutboundMessage should deserialize");
1771 assert_eq!(roundtripped, outbound);
1772 }
1773
1774 #[cfg(feature = "serde")]
1775 #[test]
1776 fn outbound_message_deserialize_rejects_invalid_payload() {
1777 let invalid_message = Message {
1780 from: None,
1781 sender: None,
1782 to: vec![Address::Mailbox(mailbox("bob@example.com"))],
1783 cc: Vec::new(),
1784 bcc: Vec::new(),
1785 reply_to: Vec::new(),
1786 subject: None,
1787 date: None,
1788 message_id: None,
1789 headers: Vec::new(),
1790 body: Body::text("hi"),
1791 attachments: Vec::new(),
1792 };
1793 let json = serde_json::to_string(&invalid_message).expect("Message should serialize");
1794 assert!(serde_json::from_str::<OutboundMessage>(&json).is_err());
1795 }
1796
1797 #[cfg(feature = "serde")]
1798 #[test]
1799 fn outbound_message_deserialize_defaults_omitted_optional_lists() {
1800 let value = serde_json::json!({
1801 "from": {"type": "mailbox", "name": null, "email": "alice@example.com"},
1802 "to": [{"type": "mailbox", "name": null, "email": "bob@example.com"}],
1803 "body": {"type": "text", "text": "hi"}
1804 });
1805
1806 let outbound: OutboundMessage = serde_json::from_value(value)
1807 .expect("defaulted collection fields should be optional on the wire");
1808
1809 assert_eq!(outbound.as_message().to().len(), 1);
1810 assert!(outbound.as_message().cc().is_empty());
1811 assert!(outbound.as_message().bcc().is_empty());
1812 assert!(outbound.as_message().reply_to().is_empty());
1813 assert!(outbound.as_message().headers().is_empty());
1814 assert!(outbound.as_message().attachments().is_empty());
1815 }
1816
1817 #[test]
1818 fn builder_constructs_valid_message() {
1819 let date = OffsetDateTime::parse("Fri, 06 Mar 2026 12:00:00 +0000", &Rfc2822)
1820 .expect("date should parse");
1821 let message_id = "<test@example.com>"
1822 .parse::<MessageId>()
1823 .expect("message id should parse");
1824
1825 let message = Message::builder(Body::text("Hello"))
1826 .from_mailbox(mailbox("Mary Smith <mary@x.test>"))
1827 .add_to(address("jdoe@one.test"))
1828 .subject("Greeting")
1829 .date(date)
1830 .message_id(message_id.clone())
1831 .add_header(Header::new("X-Test", "demo").expect("header should validate"))
1832 .build()
1833 .expect("message should validate");
1834
1835 assert!(message.from_mailbox().is_some(), "from should be set");
1836 assert_eq!(message.to().len(), 1, "expected one recipient");
1837 assert_eq!(message.date(), Some(&date));
1838 assert_eq!(message.message_id(), Some(&message_id));
1839 assert_eq!(message.headers().len(), 1);
1840 }
1841
1842 #[test]
1843 fn derive_envelope_uses_sender_and_expands_groups() {
1844 let message = Message::builder(Body::text("Hello"))
1845 .from_mailbox(mailbox("from@example.com"))
1846 .sender(mailbox("sender@example.com"))
1847 .to(vec![address("Friends: a@example.com, b@example.com;")])
1848 .add_cc(address("c@example.com"))
1849 .build()
1850 .expect("message should validate");
1851
1852 let envelope = message.derive_envelope().expect("envelope should derive");
1853
1854 assert_eq!(
1855 envelope.mail_from().map(EmailAddress::as_str),
1856 Some("sender@example.com")
1857 );
1858 assert_eq!(
1859 envelope
1860 .rcpt_to()
1861 .iter()
1862 .map(EmailAddress::as_str)
1863 .collect::<Vec<_>>(),
1864 vec!["a@example.com", "b@example.com", "c@example.com"]
1865 );
1866 }
1867
1868 #[test]
1869 fn body_convenience_constructors_create_expected_variants() {
1870 assert_eq!(Body::text("hello"), Body::Text("hello".to_owned()));
1871 assert_eq!(
1872 Body::html("<p>hello</p>"),
1873 Body::Html("<p>hello</p>".to_owned())
1874 );
1875 assert_eq!(
1876 Body::text_and_html("hello", "<p>hello</p>"),
1877 Body::TextAndHtml {
1878 text: "hello".to_owned(),
1879 html: "<p>hello</p>".to_owned(),
1880 }
1881 );
1882 }
1883
1884 #[test]
1885 fn attachment_reference_constructor_preserves_reference() {
1886 let reference = AttachmentReference::new("550e8400-e29b-41d4-a716-446655440000");
1887
1888 assert_eq!(reference.as_str(), "550e8400-e29b-41d4-a716-446655440000");
1889 }
1890
1891 #[test]
1892 fn with_attachments_replaces_existing_attachments() {
1893 let message = Message::builder(Body::text("Hello"))
1894 .from_mailbox(mailbox("from@example.com"))
1895 .add_to(address("to@example.com"))
1896 .add_attachment(
1897 Attachment::bytes(
1898 ContentType::try_from("text/plain").expect("content type should parse"),
1899 b"old".to_vec(),
1900 )
1901 .with_filename("old.txt"),
1902 )
1903 .build()
1904 .expect("message should validate");
1905
1906 let updated = message.clone().with_attachments(vec![
1907 Attachment::bytes(
1908 ContentType::try_from("text/plain").expect("content type should parse"),
1909 b"new".to_vec(),
1910 )
1911 .with_filename("new.txt"),
1912 ]);
1913
1914 assert_eq!(message.attachments().len(), 1);
1915 assert_eq!(updated.attachments().len(), 1);
1916 assert_eq!(updated.attachments()[0].filename(), Some("new.txt"));
1917 }
1918}