1use crate::email::cache::CachedSource;
2use crate::email::layout;
3use crate::email::markdown;
4use crate::email::message::{RenderedEmail, SendEmail};
5use crate::email::render;
6use crate::email::source::{FileSource, TemplateSource};
7use crate::{Error, Result};
8use lettre::message::{MultiPart, SinglePart, header::ContentType};
9use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use crate::email::config::{EmailConfig, SmtpSecurity};
14
15enum Transport {
16 Smtp(AsyncSmtpTransport<Tokio1Executor>),
17 #[cfg(any(test, feature = "test-helpers"))]
18 Stub(lettre::transport::stub::AsyncStubTransport),
19}
20
21struct Inner {
22 source: Arc<dyn TemplateSource>,
23 transport: Transport,
24 config: EmailConfig,
25 layouts: HashMap<String, String>,
26}
27
28#[derive(Clone)]
45pub struct Mailer {
46 inner: Arc<Inner>,
47}
48
49impl Mailer {
50 pub fn new(config: &EmailConfig) -> Result<Self> {
61 let file_source = FileSource::new(&config.templates_path);
62 let source: Arc<dyn TemplateSource> = if config.cache_templates {
63 Arc::new(CachedSource::new(file_source, config.template_cache_size))
64 } else {
65 Arc::new(file_source)
66 };
67
68 let transport = Self::build_smtp_transport(config)?;
69 let layouts = layout::load_layouts(&config.layouts_path)?;
70
71 Ok(Self {
72 inner: Arc::new(Inner {
73 source,
74 transport: Transport::Smtp(transport),
75 config: config.clone(),
76 layouts,
77 }),
78 })
79 }
80
81 pub fn with_source(config: &EmailConfig, source: Arc<dyn TemplateSource>) -> Result<Self> {
91 let transport = Self::build_smtp_transport(config)?;
92 let layouts = layout::load_layouts(&config.layouts_path)?;
93
94 Ok(Self {
95 inner: Arc::new(Inner {
96 source,
97 transport: Transport::Smtp(transport),
98 config: config.clone(),
99 layouts,
100 }),
101 })
102 }
103
104 #[cfg(any(test, feature = "test-helpers"))]
113 pub fn with_stub_transport(
114 config: &EmailConfig,
115 stub: lettre::transport::stub::AsyncStubTransport,
116 ) -> Result<Self> {
117 let file_source = FileSource::new(&config.templates_path);
118 let source: Arc<dyn TemplateSource> = if config.cache_templates {
119 Arc::new(CachedSource::new(file_source, config.template_cache_size))
120 } else {
121 Arc::new(file_source)
122 };
123 let layouts = layout::load_layouts(&config.layouts_path)?;
124
125 Ok(Self {
126 inner: Arc::new(Inner {
127 source,
128 transport: Transport::Stub(stub),
129 config: config.clone(),
130 layouts,
131 }),
132 })
133 }
134
135 fn build_smtp_transport(config: &EmailConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
136 match (&config.smtp.username, &config.smtp.password) {
138 (Some(_), None) | (None, Some(_)) => {
139 return Err(Error::bad_request(
140 "SMTP username and password must both be set or both be empty",
141 ));
142 }
143 _ => {}
144 }
145
146 let builder = match config.smtp.security {
147 SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp.host)
148 .map_err(|e| Error::internal(format!("SMTP relay error: {e}")))?,
149 SmtpSecurity::StartTls => {
150 AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp.host)
151 .map_err(|e| Error::internal(format!("SMTP STARTTLS error: {e}")))?
152 }
153 SmtpSecurity::None => {
154 AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp.host)
155 }
156 };
157
158 let builder = builder.port(config.smtp.port);
159
160 let builder = if let (Some(username), Some(password)) =
161 (&config.smtp.username, &config.smtp.password)
162 {
163 builder.credentials(lettre::transport::smtp::authentication::Credentials::new(
164 username.clone(),
165 password.clone(),
166 ))
167 } else {
168 builder
169 };
170
171 Ok(builder.build())
172 }
173
174 pub fn render(&self, email: &SendEmail) -> Result<RenderedEmail> {
187 let locale = email
188 .locale
189 .as_deref()
190 .unwrap_or(&self.inner.config.default_locale);
191
192 let raw =
194 self.inner
195 .source
196 .load(&email.template, locale, &self.inner.config.default_locale)?;
197
198 let substituted = render::substitute(&raw, &email.vars);
200
201 let (frontmatter, body) = render::parse_frontmatter(&substituted)?;
203
204 let brand_color = email.vars.get("brand_color").map(|s| s.as_str());
206 let html_body = markdown::markdown_to_html(&body, brand_color);
207
208 let layout_html = layout::resolve_layout(&frontmatter.layout, &self.inner.layouts)?;
210 let html = layout::apply_layout(&layout_html, &html_body, &email.vars);
211
212 let text = markdown::markdown_to_text(&body);
214
215 Ok(RenderedEmail {
216 subject: frontmatter.subject,
217 html,
218 text,
219 })
220 }
221
222 pub async fn send(&self, email: SendEmail) -> Result<()> {
234 if email.to.is_empty() {
235 return Err(Error::bad_request("email has no recipients"));
236 }
237
238 let rendered = self.render(&email)?;
239
240 let from_name = email
242 .sender
243 .as_ref()
244 .map(|s| &s.from_name)
245 .unwrap_or(&self.inner.config.default_from_name);
246 let from_email = email
247 .sender
248 .as_ref()
249 .map(|s| &s.from_email)
250 .unwrap_or(&self.inner.config.default_from_email);
251 let reply_to = email
252 .sender
253 .as_ref()
254 .and_then(|s| s.reply_to.as_deref())
255 .or(self.inner.config.default_reply_to.as_deref());
256
257 let from = if from_name.is_empty() {
258 from_email.parse()
259 } else {
260 format!("{from_name} <{from_email}>").parse()
261 }
262 .map_err(|e| Error::bad_request(format!("invalid from address: {e}")))?;
263
264 let mut builder = Message::builder().from(from).subject(&rendered.subject);
265
266 for to_addr in &email.to {
267 builder = builder.to(to_addr
268 .parse()
269 .map_err(|e| Error::bad_request(format!("invalid to address '{to_addr}': {e}")))?);
270 }
271
272 for cc_addr in &email.cc {
273 builder = builder.cc(cc_addr
274 .parse()
275 .map_err(|e| Error::bad_request(format!("invalid cc address '{cc_addr}': {e}")))?);
276 }
277
278 for bcc_addr in &email.bcc {
279 builder = builder.bcc(bcc_addr.parse().map_err(|e| {
280 Error::bad_request(format!("invalid bcc address '{bcc_addr}': {e}"))
281 })?);
282 }
283
284 if let Some(reply_to_addr) = reply_to {
285 builder = builder.reply_to(
286 reply_to_addr
287 .parse()
288 .map_err(|e| Error::bad_request(format!("invalid reply-to address: {e}")))?,
289 );
290 }
291
292 let message = builder
293 .multipart(
294 MultiPart::alternative()
295 .singlepart(
296 SinglePart::builder()
297 .header(ContentType::TEXT_PLAIN)
298 .body(rendered.text),
299 )
300 .singlepart(
301 SinglePart::builder()
302 .header(ContentType::TEXT_HTML)
303 .body(rendered.html),
304 ),
305 )
306 .map_err(|e| Error::internal(format!("failed to build email message: {e}")))?;
307
308 match &self.inner.transport {
309 Transport::Smtp(transport) => {
310 transport
311 .send(message)
312 .await
313 .map_err(|e| Error::internal(format!("failed to send email: {e}")))?;
314 }
315 #[cfg(any(test, feature = "test-helpers"))]
316 Transport::Stub(transport) => {
317 transport
318 .send(message)
319 .await
320 .map_err(|e| Error::internal(format!("failed to send email (stub): {e}")))?;
321 }
322 }
323
324 Ok(())
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331 use crate::email::config::SmtpConfig;
332
333 fn test_email_config(smtp: SmtpConfig) -> EmailConfig {
334 EmailConfig {
335 templates_path: "/tmp/nonexistent".into(),
336 layouts_path: "/tmp/nonexistent".into(),
337 default_from_name: "Test".into(),
338 default_from_email: "test@example.com".into(),
339 default_reply_to: None,
340 default_locale: "en".into(),
341 cache_templates: false,
342 template_cache_size: 10,
343 smtp,
344 }
345 }
346
347 #[test]
348 fn build_smtp_transport_username_without_password() {
349 let config = test_email_config(SmtpConfig {
350 host: "localhost".into(),
351 port: 25,
352 username: Some("user".into()),
353 password: None,
354 security: SmtpSecurity::None,
355 });
356 let result = Mailer::build_smtp_transport(&config);
357 assert!(result.is_err());
358 }
359
360 #[test]
361 fn build_smtp_transport_password_without_username() {
362 let config = test_email_config(SmtpConfig {
363 host: "localhost".into(),
364 port: 25,
365 username: None,
366 password: Some("pass".into()),
367 security: SmtpSecurity::None,
368 });
369 let result = Mailer::build_smtp_transport(&config);
370 assert!(result.is_err());
371 }
372
373 #[test]
374 fn with_source_creates_mailer() {
375 struct MockSource;
376 impl TemplateSource for MockSource {
377 fn load(&self, _name: &str, _locale: &str, _default_locale: &str) -> Result<String> {
378 Ok("---\nsubject: Test\n---\nBody".into())
379 }
380 }
381
382 let config = test_email_config(SmtpConfig {
383 host: "localhost".into(),
384 port: 25,
385 username: None,
386 password: None,
387 security: SmtpSecurity::None,
388 });
389 let source: Arc<dyn TemplateSource> = Arc::new(MockSource);
390 let mailer = Mailer::with_source(&config, source).unwrap();
391
392 let email = SendEmail::new("any", "user@example.com");
393 let rendered = mailer.render(&email).unwrap();
394 assert_eq!(rendered.subject, "Test");
395 }
396}