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 html = if self.inner.config.inline_css {
214 render::inline_css_pass(&html)?
215 } else {
216 html
217 };
218
219 let text = markdown::markdown_to_text(&body);
221
222 Ok(RenderedEmail {
223 subject: frontmatter.subject,
224 html,
225 text,
226 })
227 }
228
229 pub async fn send(&self, email: SendEmail) -> Result<()> {
241 if email.to.is_empty() {
242 return Err(Error::bad_request("email has no recipients"));
243 }
244
245 let rendered = self.render(&email)?;
246
247 let from_name = email
249 .sender
250 .as_ref()
251 .map(|s| &s.from_name)
252 .unwrap_or(&self.inner.config.default_from_name);
253 let from_email = email
254 .sender
255 .as_ref()
256 .map(|s| &s.from_email)
257 .unwrap_or(&self.inner.config.default_from_email);
258 let reply_to = email
259 .sender
260 .as_ref()
261 .and_then(|s| s.reply_to.as_deref())
262 .or(self.inner.config.default_reply_to.as_deref());
263
264 let from = if from_name.is_empty() {
265 from_email.parse()
266 } else {
267 format!("{from_name} <{from_email}>").parse()
268 }
269 .map_err(|e| Error::bad_request(format!("invalid from address: {e}")))?;
270
271 let mut builder = Message::builder().from(from).subject(&rendered.subject);
272
273 for to_addr in &email.to {
274 builder = builder.to(to_addr
275 .parse()
276 .map_err(|e| Error::bad_request(format!("invalid to address '{to_addr}': {e}")))?);
277 }
278
279 for cc_addr in &email.cc {
280 builder = builder.cc(cc_addr
281 .parse()
282 .map_err(|e| Error::bad_request(format!("invalid cc address '{cc_addr}': {e}")))?);
283 }
284
285 for bcc_addr in &email.bcc {
286 builder = builder.bcc(bcc_addr.parse().map_err(|e| {
287 Error::bad_request(format!("invalid bcc address '{bcc_addr}': {e}"))
288 })?);
289 }
290
291 if let Some(reply_to_addr) = reply_to {
292 builder = builder.reply_to(
293 reply_to_addr
294 .parse()
295 .map_err(|e| Error::bad_request(format!("invalid reply-to address: {e}")))?,
296 );
297 }
298
299 let message = builder
300 .multipart(
301 MultiPart::alternative()
302 .singlepart(
303 SinglePart::builder()
304 .header(ContentType::TEXT_PLAIN)
305 .body(rendered.text),
306 )
307 .singlepart(
308 SinglePart::builder()
309 .header(ContentType::TEXT_HTML)
310 .body(rendered.html),
311 ),
312 )
313 .map_err(|e| Error::internal(format!("failed to build email message: {e}")))?;
314
315 match &self.inner.transport {
316 Transport::Smtp(transport) => {
317 transport
318 .send(message)
319 .await
320 .map_err(|e| Error::internal(format!("failed to send email: {e}")))?;
321 }
322 #[cfg(any(test, feature = "test-helpers"))]
323 Transport::Stub(transport) => {
324 transport
325 .send(message)
326 .await
327 .map_err(|e| Error::internal(format!("failed to send email (stub): {e}")))?;
328 }
329 }
330
331 Ok(())
332 }
333}
334
335#[cfg(test)]
336mod tests {
337 use super::*;
338 use crate::email::config::SmtpConfig;
339 use crate::email::source::TemplateSource;
340
341 fn test_email_config(smtp: SmtpConfig) -> EmailConfig {
342 EmailConfig {
343 templates_path: "/tmp/nonexistent".into(),
344 layouts_path: "/tmp/nonexistent".into(),
345 default_from_name: "Test".into(),
346 default_from_email: "test@example.com".into(),
347 default_reply_to: None,
348 default_locale: "en".into(),
349 cache_templates: false,
350 template_cache_size: 10,
351 inline_css: true,
352 smtp,
353 }
354 }
355
356 #[test]
357 fn build_smtp_transport_username_without_password() {
358 let config = test_email_config(SmtpConfig {
359 host: "localhost".into(),
360 port: 25,
361 username: Some("user".into()),
362 password: None,
363 security: SmtpSecurity::None,
364 });
365 let result = Mailer::build_smtp_transport(&config);
366 assert!(result.is_err());
367 }
368
369 #[test]
370 fn build_smtp_transport_password_without_username() {
371 let config = test_email_config(SmtpConfig {
372 host: "localhost".into(),
373 port: 25,
374 username: None,
375 password: Some("pass".into()),
376 security: SmtpSecurity::None,
377 });
378 let result = Mailer::build_smtp_transport(&config);
379 assert!(result.is_err());
380 }
381
382 #[test]
383 fn with_source_creates_mailer() {
384 struct MockSource;
385 impl TemplateSource for MockSource {
386 fn load(&self, _name: &str, _locale: &str, _default_locale: &str) -> Result<String> {
387 Ok("---\nsubject: Test\n---\nBody".into())
388 }
389 }
390
391 let config = test_email_config(SmtpConfig {
392 host: "localhost".into(),
393 port: 25,
394 username: None,
395 password: None,
396 security: SmtpSecurity::None,
397 });
398 let source: Arc<dyn TemplateSource> = Arc::new(MockSource);
399 let mailer = Mailer::with_source(&config, source).unwrap();
400
401 let email = SendEmail::new("any", "user@example.com");
402 let rendered = mailer.render(&email).unwrap();
403 assert_eq!(rendered.subject, "Test");
404 }
405
406 #[test]
407 fn render_inlines_css_by_default() {
408 struct Src;
409 impl TemplateSource for Src {
410 fn load(&self, _: &str, _: &str, _: &str) -> Result<String> {
411 Ok("---\nsubject: T\n---\n# Heading".into())
412 }
413 }
414 let config = test_email_config(SmtpConfig {
415 host: "localhost".into(),
416 port: 25,
417 username: None,
418 password: None,
419 security: SmtpSecurity::None,
420 });
421 let mailer = Mailer::with_source(&config, Arc::new(Src)).unwrap();
422 let rendered = mailer.render(&SendEmail::new("x", "a@b.c")).unwrap();
423 assert!(rendered.html.contains("prefers-color-scheme: dark"));
425 let style_end = rendered.html.find("</style>").expect("has <style>");
430 let after_style = &rendered.html[style_end..];
431 assert!(
432 after_style.contains("-webkit-text-size-adjust"),
433 "inliner should copy -webkit-text-size-adjust into inline style, got: {after_style:.500}"
434 );
435 }
436
437 #[test]
438 fn render_skips_inliner_when_disabled() {
439 struct Src;
440 impl TemplateSource for Src {
441 fn load(&self, _: &str, _: &str, _: &str) -> Result<String> {
442 Ok("---\nsubject: T\n---\nBody".into())
443 }
444 }
445 let mut config = test_email_config(SmtpConfig {
446 host: "localhost".into(),
447 port: 25,
448 username: None,
449 password: None,
450 security: SmtpSecurity::None,
451 });
452 config.inline_css = false;
453 let mailer = Mailer::with_source(&config, Arc::new(Src)).unwrap();
454 let rendered = mailer.render(&SendEmail::new("x", "a@b.c")).unwrap();
455 assert!(!rendered.html.is_empty());
456 assert!(rendered.html.contains("prefers-color-scheme: dark"));
457 let style_end = rendered.html.find("</style>").expect("has <style>");
460 let after_style = &rendered.html[style_end..];
461 assert!(
462 !after_style.contains("-webkit-text-size-adjust"),
463 "inliner should not run when disabled, got: {after_style:.500}"
464 );
465 }
466}