Skip to main content

modo/email/
message.rs

1use std::collections::HashMap;
2
3/// A rendered email ready for sending.
4///
5/// Produced by [`Mailer::render`](crate::email::Mailer::render). Contains the
6/// fully substituted subject line, the HTML body (with layout applied), and
7/// the plain-text fallback.
8pub struct RenderedEmail {
9    /// The email subject line, taken from the template frontmatter.
10    pub subject: String,
11    /// The fully rendered HTML body with layout applied.
12    pub html: String,
13    /// Plain-text fallback body derived from the Markdown source.
14    pub text: String,
15}
16
17/// Overrides the default sender for a specific email.
18///
19/// When attached to a [`SendEmail`] via [`SendEmail::sender`], these values
20/// take precedence over the `default_from_*` fields in
21/// [`EmailConfig`](crate::email::EmailConfig).
22pub struct SenderProfile {
23    /// Display name for the `From` header.
24    pub from_name: String,
25    /// Email address for the `From` header.
26    pub from_email: String,
27    /// Optional `Reply-To` address.
28    pub reply_to: Option<String>,
29}
30
31/// Builder for composing an email to send.
32///
33/// Created with [`SendEmail::new`] and configured via builder-style methods.
34/// Pass the completed value to [`Mailer::send`](crate::email::Mailer::send).
35pub struct SendEmail {
36    /// Template name to render (without locale prefix or `.md` extension).
37    pub template: String,
38    /// Primary recipients (`To` header).
39    pub to: Vec<String>,
40    /// Carbon-copy recipients (`Cc` header).
41    pub cc: Vec<String>,
42    /// Blind carbon-copy recipients (`Bcc` header).
43    pub bcc: Vec<String>,
44    /// Optional locale override. Falls back to `EmailConfig::default_locale`.
45    pub locale: Option<String>,
46    /// Variables substituted into `{{var_name}}` placeholders in the template.
47    pub vars: HashMap<String, String>,
48    /// Optional sender override. Falls back to [`EmailConfig`](crate::email::EmailConfig) defaults.
49    pub sender: Option<SenderProfile>,
50}
51
52impl SendEmail {
53    /// Create a new email builder for the given template and first recipient.
54    ///
55    /// Additional recipients can be added with [`Self::to`], [`Self::cc`],
56    /// and [`Self::bcc`].
57    pub fn new(template: impl Into<String>, to: impl Into<String>) -> Self {
58        Self {
59            template: template.into(),
60            to: vec![to.into()],
61            cc: Vec::new(),
62            bcc: Vec::new(),
63            locale: None,
64            vars: HashMap::new(),
65            sender: None,
66        }
67    }
68
69    /// Add a `To` recipient.
70    pub fn to(mut self, addr: impl Into<String>) -> Self {
71        self.to.push(addr.into());
72        self
73    }
74
75    /// Add a `Cc` recipient.
76    pub fn cc(mut self, addr: impl Into<String>) -> Self {
77        self.cc.push(addr.into());
78        self
79    }
80
81    /// Add a `Bcc` recipient.
82    pub fn bcc(mut self, addr: impl Into<String>) -> Self {
83        self.bcc.push(addr.into());
84        self
85    }
86
87    /// Override the locale used to load this template.
88    pub fn locale(mut self, locale: impl Into<String>) -> Self {
89        self.locale = Some(locale.into());
90        self
91    }
92
93    /// Insert or overwrite a template variable.
94    ///
95    /// The value is substituted for every `{{key}}` occurrence in both
96    /// frontmatter and body.
97    pub fn var(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
98        self.vars.insert(key.into(), value.into());
99        self
100    }
101
102    /// Override the sender profile for this email.
103    pub fn sender(mut self, profile: SenderProfile) -> Self {
104        self.sender = Some(profile);
105        self
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    use super::*;
112
113    #[test]
114    fn new_sets_template_and_first_recipient() {
115        let email = SendEmail::new("welcome", "user@example.com");
116        assert_eq!(email.template, "welcome");
117        assert_eq!(email.to, vec!["user@example.com"]);
118        assert!(email.cc.is_empty());
119        assert!(email.bcc.is_empty());
120        assert!(email.locale.is_none());
121        assert!(email.vars.is_empty());
122        assert!(email.sender.is_none());
123    }
124
125    #[test]
126    fn builder_chain() {
127        let email = SendEmail::new("reset", "a@example.com")
128            .to("b@example.com")
129            .cc("c@example.com")
130            .bcc("d@example.com")
131            .locale("uk")
132            .var("name", "Dmytro")
133            .var("token", "abc123")
134            .sender(SenderProfile {
135                from_name: "Support".into(),
136                from_email: "support@app.com".into(),
137                reply_to: Some("help@app.com".into()),
138            });
139        assert_eq!(email.to, vec!["a@example.com", "b@example.com"]);
140        assert_eq!(email.cc, vec!["c@example.com"]);
141        assert_eq!(email.bcc, vec!["d@example.com"]);
142        assert_eq!(email.locale.as_deref(), Some("uk"));
143        assert_eq!(email.vars.get("name").unwrap(), "Dmytro");
144        assert_eq!(email.vars.get("token").unwrap(), "abc123");
145        let sender = email.sender.unwrap();
146        assert_eq!(sender.from_name, "Support");
147        assert_eq!(sender.from_email, "support@app.com");
148        assert_eq!(sender.reply_to.as_deref(), Some("help@app.com"));
149    }
150
151    #[test]
152    fn var_overwrites_previous_value() {
153        let email = SendEmail::new("t", "a@b.com")
154            .var("key", "old")
155            .var("key", "new");
156        assert_eq!(email.vars.get("key").unwrap(), "new");
157    }
158}