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 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#[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 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#[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 #[default]
211 Attachment,
212 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 #[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 #[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#[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#[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#[derive(Clone, Debug, PartialEq, Eq)]
458#[non_exhaustive]
459pub struct OutboundMessage {
460 inner: Message,
462 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 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 pub fn new(message: Message) -> Result<Self, MessageValidationError> {
515 message.validate_basic()?;
516 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 #[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#[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
657fn 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
711const 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 #[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 #[must_use]
760 pub const fn builder(body: Body) -> MessageBuilder {
761 MessageBuilder::new(body)
762 }
763
764 #[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 #[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 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 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#[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
1119 pub fn add_attachment(mut self, attachment: Attachment) -> Self {
1120 self.message.attachments.push(attachment);
1121 self
1122 }
1123
1124 #[must_use]
1138 pub fn build_unchecked(self) -> Message {
1139 self.message
1140 }
1141
1142 pub fn build(self) -> Result<Message, MessageValidationError> {
1149 self.message.validate_basic()?;
1150 Ok(self.message)
1151 }
1152
1153 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 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 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}