Skip to main content

modo_email/
message.rs

1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4/// Sender identity for outgoing emails.
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SenderProfile {
7    /// Display name shown in the `From` header.
8    pub from_name: String,
9    /// Email address used in the `From` header.
10    pub from_email: String,
11    /// Optional `Reply-To` address.
12    pub reply_to: Option<String>,
13}
14
15impl SenderProfile {
16    /// Format as `"Name <email>"` for the From header.
17    ///
18    /// Strips control characters and angle brackets from the name, and control
19    /// characters from the email, to prevent header injection.
20    pub fn format_address(&self) -> String {
21        let safe_name: String = self
22            .from_name
23            .chars()
24            .filter(|c| !c.is_control() && *c != '<' && *c != '>')
25            .collect();
26        let safe_email: String = self
27            .from_email
28            .chars()
29            .filter(|c| !c.is_control())
30            .collect();
31        format!("{} <{}>", safe_name.trim(), safe_email.trim())
32    }
33}
34
35/// A fully-rendered email ready for transport.
36#[derive(Debug, Clone)]
37pub struct MailMessage {
38    /// Formatted `From` address (e.g. `"Name <email@example.com>"`).
39    pub from: String,
40    /// Optional `Reply-To` address.
41    pub reply_to: Option<String>,
42    /// List of recipient addresses.
43    pub to: Vec<String>,
44    /// Email subject line with variables already substituted.
45    pub subject: String,
46    /// Rendered HTML body wrapped in a layout.
47    pub html: String,
48    /// Plain-text body derived from Markdown.
49    pub text: String,
50}
51
52/// Builder for requesting a templated email send.
53///
54/// Create with [`SendEmail::new`], then chain builder methods to set
55/// recipients, locale, sender override, and template variables before
56/// passing to `Mailer::send` or `Mailer::render`.
57#[derive(Debug, Clone)]
58pub struct SendEmail {
59    pub(crate) template: String,
60    pub(crate) to: Vec<String>,
61    pub(crate) locale: Option<String>,
62    pub(crate) sender: Option<SenderProfile>,
63    pub(crate) context: HashMap<String, serde_json::Value>,
64}
65
66impl SendEmail {
67    /// Create a new send request for the named template addressed to `to`.
68    pub fn new(template: &str, to: &str) -> Self {
69        Self {
70            template: template.to_string(),
71            to: vec![to.to_string()],
72            locale: None,
73            sender: None,
74            context: HashMap::new(),
75        }
76    }
77
78    /// Add an additional recipient.
79    pub fn to(mut self, to: &str) -> Self {
80        self.to.push(to.to_string());
81        self
82    }
83
84    /// Set the locale used for template resolution. Falls back to the root
85    /// template when no localized variant is found.
86    pub fn locale(mut self, locale: &str) -> Self {
87        self.locale = Some(locale.to_string());
88        self
89    }
90
91    /// Override the default sender with a per-email `SenderProfile`.
92    pub fn sender(mut self, sender: &SenderProfile) -> Self {
93        self.sender = Some(sender.clone());
94        self
95    }
96
97    /// Insert a single template variable by key.
98    ///
99    /// The value can be any type that converts to [`serde_json::Value`]
100    /// (e.g. `&str`, `String`, `i64`, `bool`).
101    pub fn var(mut self, key: &str, value: impl Into<serde_json::Value>) -> Self {
102        self.context.insert(key.to_string(), value.into());
103        self
104    }
105
106    /// Merge an entire context map into the template variables.
107    ///
108    /// Existing keys are overwritten by keys in `ctx`.
109    pub fn context(mut self, ctx: &HashMap<String, serde_json::Value>) -> Self {
110        self.context.extend(ctx.clone());
111        self
112    }
113}
114
115/// Serializable mirror of [`SendEmail`] for async job queue payloads.
116///
117/// Convert from a [`SendEmail`] before enqueuing, and back to [`SendEmail`]
118/// inside the worker via the provided `From` impls.
119#[derive(Debug, Clone, Serialize, Deserialize)]
120pub struct SendEmailPayload {
121    pub template: String,
122    pub to: Vec<String>,
123    pub locale: Option<String>,
124    pub sender: Option<SenderProfile>,
125    pub context: HashMap<String, serde_json::Value>,
126}
127
128impl From<SendEmail> for SendEmailPayload {
129    fn from(e: SendEmail) -> Self {
130        Self {
131            template: e.template,
132            to: e.to,
133            locale: e.locale,
134            sender: e.sender,
135            context: e.context,
136        }
137    }
138}
139
140impl From<SendEmailPayload> for SendEmail {
141    fn from(p: SendEmailPayload) -> Self {
142        Self {
143            template: p.template,
144            to: p.to,
145            locale: p.locale,
146            sender: p.sender,
147            context: p.context,
148        }
149    }
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155
156    #[test]
157    fn sender_profile_serialization_roundtrip() {
158        let profile = SenderProfile {
159            from_name: "Acme".to_string(),
160            from_email: "hi@acme.com".to_string(),
161            reply_to: Some("support@acme.com".to_string()),
162        };
163        let json = serde_json::to_string(&profile).unwrap();
164        let back: SenderProfile = serde_json::from_str(&json).unwrap();
165        assert_eq!(back.from_name, "Acme");
166        assert_eq!(back.reply_to, Some("support@acme.com".to_string()));
167    }
168
169    #[test]
170    fn sender_profile_format_address() {
171        let profile = SenderProfile {
172            from_name: "Acme Corp".to_string(),
173            from_email: "hi@acme.com".to_string(),
174            reply_to: None,
175        };
176        assert_eq!(profile.format_address(), "Acme Corp <hi@acme.com>");
177    }
178
179    #[test]
180    fn sender_profile_sanitizes_name() {
181        let profile = SenderProfile {
182            from_name: "Evil<script>".to_string(),
183            from_email: "hi@acme.com".to_string(),
184            reply_to: None,
185        };
186        assert_eq!(profile.format_address(), "Evilscript <hi@acme.com>");
187    }
188
189    #[test]
190    fn sender_profile_sanitizes_email() {
191        let profile = SenderProfile {
192            from_name: "Acme".to_string(),
193            from_email: "hi@acme.com\r\nBcc: evil@x.com".to_string(),
194            reply_to: None,
195        };
196        let addr = profile.format_address();
197        assert!(!addr.contains('\r'));
198        assert!(!addr.contains('\n'));
199    }
200
201    #[test]
202    fn send_email_builder() {
203        let email = SendEmail::new("welcome", "user@test.com")
204            .locale("de")
205            .var("name", "Hans")
206            .var("code", "1234");
207        assert_eq!(email.template, "welcome");
208        assert_eq!(email.to, vec!["user@test.com"]);
209        assert_eq!(email.locale.as_deref(), Some("de"));
210        assert_eq!(email.context.len(), 2);
211    }
212
213    #[test]
214    fn send_email_multiple_recipients() {
215        let email = SendEmail::new("welcome", "a@test.com")
216            .to("b@test.com")
217            .to("c@test.com");
218        assert_eq!(email.to, vec!["a@test.com", "b@test.com", "c@test.com"]);
219    }
220
221    #[test]
222    fn send_email_context_merge() {
223        let mut brand = HashMap::new();
224        brand.insert("logo".to_string(), serde_json::json!("https://logo.png"));
225        brand.insert("color".to_string(), serde_json::json!("#ff0000"));
226
227        let email = SendEmail::new("welcome", "u@t.com")
228            .context(&brand)
229            .var("name", "Alice");
230        assert_eq!(email.context.len(), 3);
231    }
232
233    #[test]
234    fn payload_roundtrip() {
235        let email = SendEmail::new("welcome", "u@t.com")
236            .to("v@t.com")
237            .locale("en")
238            .var("name", "Alice");
239        let payload = SendEmailPayload::from(email);
240        let json = serde_json::to_string(&payload).unwrap();
241        let back: SendEmailPayload = serde_json::from_str(&json).unwrap();
242        assert_eq!(back.template, "welcome");
243        assert_eq!(back.locale.as_deref(), Some("en"));
244        assert_eq!(back.to, vec!["u@t.com", "v@t.com"]);
245
246        let email_back = SendEmail::from(back);
247        assert_eq!(email_back.to, vec!["u@t.com", "v@t.com"]);
248    }
249}