1use serde::{Deserialize, Serialize};
2use std::collections::HashMap;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct SenderProfile {
7 pub from_name: String,
9 pub from_email: String,
11 pub reply_to: Option<String>,
13}
14
15impl SenderProfile {
16 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#[derive(Debug, Clone)]
37pub struct MailMessage {
38 pub from: String,
40 pub reply_to: Option<String>,
42 pub to: Vec<String>,
44 pub subject: String,
46 pub html: String,
48 pub text: String,
50}
51
52#[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 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 pub fn to(mut self, to: &str) -> Self {
80 self.to.push(to.to_string());
81 self
82 }
83
84 pub fn locale(mut self, locale: &str) -> Self {
87 self.locale = Some(locale.to_string());
88 self
89 }
90
91 pub fn sender(mut self, sender: &SenderProfile) -> Self {
93 self.sender = Some(sender.clone());
94 self
95 }
96
97 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 pub fn context(mut self, ctx: &HashMap<String, serde_json::Value>) -> Self {
110 self.context.extend(ctx.clone());
111 self
112 }
113}
114
115#[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}