Skip to main content

oxidite_mail/
message.rs

1use crate::{Attachment, Result, MailError};
2use lettre::Address;
3
4/// Email message builder (Nodemailer-style)
5#[derive(Debug, Clone)]
6pub struct Message {
7    pub(crate) from: Option<String>,
8    pub(crate) to: Vec<String>,
9    pub(crate) cc: Vec<String>,
10    pub(crate) bcc: Vec<String>,
11    pub(crate) reply_to: Option<String>,
12    pub(crate) subject: Option<String>,
13    pub(crate) text: Option<String>,
14    pub(crate) html: Option<String>,
15    pub(crate) attachments: Vec<Attachment>,
16}
17
18impl Message {
19    pub fn new() -> Self {
20        Self {
21            from: None,
22            to: Vec::new(),
23            cc: Vec::new(),
24            bcc: Vec::new(),
25            reply_to: None,
26            subject: None,
27            text: None,
28            html: None,
29            attachments: Vec::new(),
30        }
31    }
32
33    /// Set sender address
34    pub fn from(mut self, from: impl Into<String>) -> Self {
35        self.from = Some(from.into());
36        self
37    }
38
39    /// Add recipient
40    pub fn to(mut self, to: impl Into<String>) -> Self {
41        self.to.push(to.into());
42        self
43    }
44
45    /// Add CC recipient
46    pub fn cc(mut self, cc: impl Into<String>) -> Self {
47        self.cc.push(cc.into());
48        self
49    }
50
51    /// Add BCC recipient
52    pub fn bcc(mut self, bcc: impl Into<String>) -> Self {
53        self.bcc.push(bcc.into());
54        self
55    }
56
57    /// Set reply-to address
58    pub fn reply_to(mut self, reply_to: impl Into<String>) -> Self {
59        self.reply_to = Some(reply_to.into());
60        self
61    }
62
63    /// Set subject
64    pub fn subject(mut self, subject: impl Into<String>) -> Self {
65        self.subject = Some(subject.into());
66        self
67    }
68
69    /// Set plain text body
70    pub fn text(mut self, text: impl Into<String>) -> Self {
71        self.text = Some(text.into());
72        self
73    }
74
75    /// Set HTML body
76    pub fn html(mut self, html: impl Into<String>) -> Self {
77        self.html = Some(html.into());
78        self
79    }
80
81    /// Add attachment
82    pub fn attach(mut self, attachment: Attachment) -> Self {
83        self.attachments.push(attachment);
84        self
85    }
86
87    /// Validate message
88    pub(crate) fn validate(&self) -> Result<()> {
89        if self.from.is_none() {
90            return Err(MailError::MissingField("from".to_string()));
91        }
92        if self.to.is_empty() {
93            return Err(MailError::MissingField("to".to_string()));
94        }
95        if self.subject.is_none() {
96            return Err(MailError::MissingField("subject".to_string()));
97        }
98        if self.text.is_none() && self.html.is_none() {
99            return Err(MailError::MissingField("text or html".to_string()));
100        }
101
102        // Validate address formats early for clearer diagnostics.
103        if let Some(from) = &self.from {
104            from.parse::<Address>()
105                .map_err(|_| MailError::InvalidAddress(format!("from: {from}")))?;
106        }
107        for to in &self.to {
108            to.parse::<Address>()
109                .map_err(|_| MailError::InvalidAddress(format!("to: {to}")))?;
110        }
111        for cc in &self.cc {
112            cc.parse::<Address>()
113                .map_err(|_| MailError::InvalidAddress(format!("cc: {cc}")))?;
114        }
115        for bcc in &self.bcc {
116            bcc.parse::<Address>()
117                .map_err(|_| MailError::InvalidAddress(format!("bcc: {bcc}")))?;
118        }
119        if let Some(reply_to) = &self.reply_to {
120            reply_to
121                .parse::<Address>()
122                .map_err(|_| MailError::InvalidAddress(format!("reply_to: {reply_to}")))?;
123        }
124
125        Ok(())
126    }
127}
128
129impl Default for Message {
130    fn default() -> Self {
131        Self::new()
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::Message;
138
139    #[test]
140    fn validate_rejects_bad_recipient() {
141        let msg = Message::new()
142            .from("sender@example.com")
143            .to("bad-address")
144            .subject("hello")
145            .text("body");
146        assert!(msg.validate().is_err());
147    }
148}