1use std::sync::{Arc, RwLock};
27
28use async_trait::async_trait;
29use serde::{Deserialize, Serialize};
30
31use crate::config::MailConfig;
32use crate::error::{CoreError, CoreResult};
33use crate::queue::{Job, JobCx, JobError, JobReg, Pushed, Queue};
34use crate::registry::Registry;
35use crate::{Db, Module, ModuleId};
36
37mod drivers;
38pub mod migrations;
39#[cfg(feature = "smtp")]
40pub mod smtp;
41pub mod template;
42
43pub use drivers::{LogMailer, LoggedMail, StubMailer};
44pub use template::{
45 Layout, MailLayoutReg, MailPartialReg, MailTemplateReg, Partial, Rendered, Template, Templates,
46};
47
48pub struct MailModule;
51
52impl Module for MailModule {
53 fn id(&self) -> ModuleId {
54 ModuleId::new(migrations::MODULE_ID)
55 }
56 fn migrations(&self) -> crate::MigrationSet {
57 migrations::migrations()
58 }
59 fn register(&self, registry: &mut Registry) {
60 registry.add(crate::cache::CacheNamespace::new("templates"));
61 registry.add_mail_layout("default", include_str!("../../mail/layouts/default.htm"));
62 registry.add_mail_layout("plain", include_str!("../../mail/layouts/plain.htm"));
63 registry.add_mail_partial("header", include_str!("../../mail/partials/header.htm"));
64 registry.add_mail_partial("footer", include_str!("../../mail/partials/footer.htm"));
65 registry.add_mail_partial("button", include_str!("../../mail/partials/button.htm"));
66 registry.add_mail_partial("panel", include_str!("../../mail/partials/panel.htm"));
67 registry.add_mail_partial("table", include_str!("../../mail/partials/table.htm"));
68 registry.add_mail_partial("subcopy", include_str!("../../mail/partials/subcopy.htm"));
69 registry.add_mail_template(TEST_TEMPLATE, include_str!("../../mail/templates/test.htm"));
70 registry.add_job::<SendMail>();
71 }
72}
73
74use crate::queue::QueueRegistry;
75
76pub const TEST_TEMPLATE: &str = "laterite::test";
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Address {
83 pub name: Option<String>,
84 pub email: String,
85}
86
87impl Address {
88 pub fn parse(text: &str) -> Option<Self> {
91 let text = text.trim();
92 let (name, email) = match (text.rfind('<'), text.ends_with('>')) {
93 (Some(open), true) => {
94 let name = text[..open].trim().trim_matches('"').trim();
95 let email = text[open + 1..text.len() - 1].trim();
96 ((!name.is_empty()).then(|| name.to_string()), email)
97 }
98 _ => (None, text),
99 };
100 if !email.contains('@') || email.contains(char::is_whitespace) {
101 return None;
102 }
103 Some(Self {
104 name,
105 email: email.to_string(),
106 })
107 }
108}
109
110impl std::fmt::Display for Address {
111 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
112 match &self.name {
113 Some(name) => write!(f, "{name} <{}>", self.email),
114 None => f.write_str(&self.email),
115 }
116 }
117}
118
119#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
121pub struct Attachment {
122 pub filename: String,
123 pub content_type: String,
124 #[serde(with = "base64_bytes")]
125 pub bytes: Vec<u8>,
126}
127
128mod base64_bytes {
129 use base64::engine::general_purpose::STANDARD;
130 use base64::Engine;
131 use serde::{Deserialize, Deserializer, Serialize, Serializer};
132
133 pub fn serialize<S: Serializer>(bytes: &[u8], s: S) -> Result<S::Ok, S::Error> {
134 STANDARD.encode(bytes).serialize(s)
135 }
136
137 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
138 let text = String::deserialize(d)?;
139 STANDARD.decode(text).map_err(serde::de::Error::custom)
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
145pub enum Content {
146 Template {
147 code: String,
148 vars: serde_json::Value,
149 },
150 Raw {
151 subject: String,
152 text: String,
153 html: Option<String>,
154 },
155}
156
157#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct Message {
161 pub content: Content,
162 pub to: Vec<Address>,
163 pub cc: Vec<Address>,
164 pub bcc: Vec<Address>,
165 pub reply_to: Option<Address>,
166 pub from: Option<Address>,
168 pub attachments: Vec<Attachment>,
169}
170
171impl Message {
172 pub fn template(code: impl Into<String>, vars: impl Serialize) -> Self {
174 Self::with(Content::Template {
175 code: code.into(),
176 vars: serde_json::to_value(vars).unwrap_or(serde_json::Value::Null),
177 })
178 }
179
180 pub fn raw(subject: impl Into<String>, text: impl Into<String>, html: Option<String>) -> Self {
182 Self::with(Content::Raw {
183 subject: subject.into(),
184 text: text.into(),
185 html,
186 })
187 }
188
189 fn with(content: Content) -> Self {
190 Self {
191 content,
192 to: Vec::new(),
193 cc: Vec::new(),
194 bcc: Vec::new(),
195 reply_to: None,
196 from: None,
197 attachments: Vec::new(),
198 }
199 }
200
201 pub fn to(mut self, address: &str) -> Self {
203 self.to.extend(Address::parse(address));
204 self
205 }
206
207 pub fn cc(mut self, address: &str) -> Self {
208 self.cc.extend(Address::parse(address));
209 self
210 }
211
212 pub fn bcc(mut self, address: &str) -> Self {
213 self.bcc.extend(Address::parse(address));
214 self
215 }
216
217 pub fn reply_to(mut self, address: &str) -> Self {
218 self.reply_to = Address::parse(address);
219 self
220 }
221
222 pub fn from(mut self, address: &str) -> Self {
223 self.from = Address::parse(address);
224 self
225 }
226
227 pub fn attach(
228 mut self,
229 filename: impl Into<String>,
230 content_type: impl Into<String>,
231 bytes: Vec<u8>,
232 ) -> Self {
233 self.attachments.push(Attachment {
234 filename: filename.into(),
235 content_type: content_type.into(),
236 bytes,
237 });
238 self
239 }
240}
241
242#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
244pub struct Envelope {
245 pub from: Address,
246 pub to: Vec<Address>,
247 pub cc: Vec<Address>,
248 pub bcc: Vec<Address>,
249 pub reply_to: Option<Address>,
250 pub subject: String,
251 pub text: String,
252 pub html: Option<String>,
253 pub attachments: Vec<Attachment>,
254}
255
256#[async_trait]
259pub trait Mailer: Send + Sync {
260 fn name(&self) -> &'static str;
262 async fn deliver(&self, envelope: &Envelope) -> CoreResult<()>;
263}
264
265type BuildFn = dyn Fn(&MailConfig, &Db) -> CoreResult<Arc<dyn Mailer>> + Send + Sync;
266
267#[derive(Clone)]
270pub struct MailDriverReg {
271 name: &'static str,
272 build: Arc<BuildFn>,
273}
274
275impl MailDriverReg {
276 pub fn new<F>(name: &'static str, build: F) -> Self
277 where
278 F: Fn(&MailConfig, &Db) -> CoreResult<Arc<dyn Mailer>> + Send + Sync + 'static,
279 {
280 Self {
281 name,
282 build: Arc::new(build),
283 }
284 }
285
286 pub fn name(&self) -> &'static str {
287 self.name
288 }
289
290 pub fn build(&self, config: &MailConfig, db: &Db) -> CoreResult<Arc<dyn Mailer>> {
291 (self.build)(config, db)
292 }
293}
294
295impl std::fmt::Debug for MailDriverReg {
296 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
297 f.debug_struct("MailDriverReg")
298 .field("name", &self.name)
299 .finish()
300 }
301}
302
303pub trait MailRegistry {
305 fn add_mail_template(&mut self, code: &'static str, file: &'static str);
307 fn add_mail_layout(&mut self, code: &'static str, file: &'static str);
309 fn add_mail_partial(&mut self, code: &'static str, file: &'static str);
311 fn add_mail_driver(&mut self, driver: MailDriverReg);
312}
313
314impl MailRegistry for Registry {
315 fn add_mail_template(&mut self, code: &'static str, file: &'static str) {
316 self.add(MailTemplateReg { code, file });
317 }
318 fn add_mail_layout(&mut self, code: &'static str, file: &'static str) {
319 self.add(MailLayoutReg { code, file });
320 }
321 fn add_mail_partial(&mut self, code: &'static str, file: &'static str) {
322 self.add(MailPartialReg { code, file });
323 }
324 fn add_mail_driver(&mut self, driver: MailDriverReg) {
325 self.add(driver);
326 }
327}
328
329pub fn drivers(extra: impl IntoIterator<Item = MailDriverReg>) -> Vec<MailDriverReg> {
331 let mut all = vec![
332 MailDriverReg::new("log", |_, db| Ok(Arc::new(LogMailer::new(db.clone())))),
333 MailDriverReg::new("stub", |_, _| Ok(Arc::new(StubMailer::new()))),
334 ];
335 #[cfg(feature = "smtp")]
336 all.push(MailDriverReg::new("smtp", |config, _| {
337 Ok(Arc::new(smtp::SmtpMailer::from_config(&config.smtp)?))
338 }));
339 all.extend(extra);
340 all
341}
342
343pub fn build_driver(
345 config: &MailConfig,
346 db: &Db,
347 available: &[MailDriverReg],
348) -> CoreResult<Arc<dyn Mailer>> {
349 let reg = available
350 .iter()
351 .find(|r| r.name == config.driver)
352 .ok_or_else(|| {
353 let names: Vec<&str> = available.iter().map(|r| r.name).collect();
354 CoreError::Config(format!(
355 "mail.driver `{}` is not one of {}",
356 config.driver,
357 names.join(", ")
358 ))
359 })?;
360 reg.build(config, db)
361}
362
363#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct Sender {
367 pub from: Address,
368 pub to: Option<Address>,
370}
371
372impl Sender {
373 pub fn from_config(config: &MailConfig) -> CoreResult<Self> {
375 let from = Address::parse(&config.from).ok_or_else(|| {
376 CoreError::Config(format!("mail.from `{}` is not an address", config.from))
377 })?;
378 let to =
379 match config
380 .to
381 .as_deref()
382 .map(str::trim)
383 .filter(|t| !t.is_empty())
384 {
385 Some(text) => Some(Address::parse(text).ok_or_else(|| {
386 CoreError::Config(format!("mail.to `{text}` is not an address"))
387 })?),
388 None => None,
389 };
390 Ok(Self { from, to })
391 }
392}
393
394struct Transport {
395 mailer: Arc<dyn Mailer>,
396 sender: Sender,
397}
398
399struct Inner {
400 transport: RwLock<Transport>,
401 templates: Templates,
402 queue: Queue,
403 available: Vec<MailDriverReg>,
404 db: Db,
405}
406
407#[derive(Clone)]
409pub struct Mail {
410 inner: Arc<Inner>,
411}
412
413impl std::fmt::Debug for Mail {
414 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
415 f.debug_struct("Mail")
416 .field("driver", &self.driver_name())
417 .finish_non_exhaustive()
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
423pub struct SendMail {
424 pub message: Message,
425}
426
427#[async_trait]
428impl Job for SendMail {
429 const NAME: &'static str = "laterite.mail::send";
430
431 async fn run(&self, _cx: &JobCx) -> Result<(), JobError> {
434 Err(JobError::fail("the mail job runs through the mail service"))
435 }
436
437 fn tries(&self) -> u32 {
438 5
439 }
440
441 fn backoff(&self, attempt: u32) -> std::time::Duration {
442 std::time::Duration::from_secs(30u64 << attempt.saturating_sub(1).min(6))
443 }
444}
445
446impl Mail {
447 pub fn new(
451 mailer: Arc<dyn Mailer>,
452 sender: Sender,
453 templates: Templates,
454 queue: Queue,
455 available: Vec<MailDriverReg>,
456 db: Db,
457 ) -> Self {
458 let mail = Self {
459 inner: Arc::new(Inner {
460 transport: RwLock::new(Transport { mailer, sender }),
461 templates,
462 queue: queue.clone(),
463 available,
464 db,
465 }),
466 };
467 queue.register(mail.job_reg());
468 mail
469 }
470
471 pub fn from_config(
474 config: &MailConfig,
475 extra: impl IntoIterator<Item = MailDriverReg>,
476 templates: Templates,
477 queue: Queue,
478 db: Db,
479 ) -> CoreResult<Self> {
480 let available = drivers(extra);
481 let mailer = build_driver(config, &db, &available)?;
482 let sender = Sender::from_config(config)?;
483 Ok(Self::new(mailer, sender, templates, queue, available, db))
484 }
485
486 pub fn reconfigure(&self, config: &MailConfig) -> CoreResult<()> {
488 let mailer = build_driver(config, &self.inner.db, &self.inner.available)?;
489 let sender = Sender::from_config(config)?;
490 *self
491 .inner
492 .transport
493 .write()
494 .unwrap_or_else(|e| e.into_inner()) = Transport { mailer, sender };
495 Ok(())
496 }
497
498 pub fn set_mailer(&self, mailer: Arc<dyn Mailer>) {
500 self.inner
501 .transport
502 .write()
503 .unwrap_or_else(|e| e.into_inner())
504 .mailer = mailer;
505 }
506
507 pub fn driver_name(&self) -> &'static str {
508 self.inner
509 .transport
510 .read()
511 .unwrap_or_else(|e| e.into_inner())
512 .mailer
513 .name()
514 }
515
516 pub fn driver_names(&self) -> Vec<&'static str> {
518 let mut names: Vec<&'static str> = self.inner.available.iter().map(|r| r.name).collect();
519 names.sort_unstable();
520 names
521 }
522
523 pub fn sender(&self) -> Sender {
524 self.inner
525 .transport
526 .read()
527 .unwrap_or_else(|e| e.into_inner())
528 .sender
529 .clone()
530 }
531
532 pub fn templates(&self) -> &Templates {
534 &self.inner.templates
535 }
536
537 fn job_reg(&self) -> JobReg {
538 let mail = self.clone();
539 JobReg::with_handler::<SendMail, _, _>(move |job: SendMail, _cx: JobCx| {
540 let mail = mail.clone();
541 async move {
542 mail.send_now(job.message)
543 .await
544 .map_err(|e| JobError::retry(e.to_string()))
545 }
546 })
547 }
548
549 pub async fn send(&self, message: Message) -> CoreResult<Pushed> {
552 if message.to.is_empty() {
553 return Err(CoreError::Validation(
554 "the message has no recipient".to_string(),
555 ));
556 }
557 self.inner.queue.push(&SendMail { message }).await
558 }
559
560 pub async fn send_now(&self, message: Message) -> CoreResult<()> {
562 let envelope = self.envelope(message).await?;
563 let mailer = self
564 .inner
565 .transport
566 .read()
567 .unwrap_or_else(|e| e.into_inner())
568 .mailer
569 .clone();
570 mailer.deliver(&envelope).await
571 }
572
573 pub async fn envelope(&self, message: Message) -> CoreResult<Envelope> {
576 if message.to.is_empty() {
577 return Err(CoreError::Validation(
578 "the message has no recipient".to_string(),
579 ));
580 }
581 let rendered = match &message.content {
582 Content::Template { code, vars } => self.inner.templates.render(code, vars).await?,
583 Content::Raw {
584 subject,
585 text,
586 html,
587 } => Rendered {
588 subject: subject.clone(),
589 text: text.clone(),
590 html: html.clone(),
591 },
592 };
593 let sender = self.sender();
594 let (to, cc, bcc) = match sender.to {
595 Some(dev) => (vec![dev], Vec::new(), Vec::new()),
596 None => (message.to, message.cc, message.bcc),
597 };
598 Ok(Envelope {
599 from: message.from.unwrap_or(sender.from),
600 to,
601 cc,
602 bcc,
603 reply_to: message.reply_to,
604 subject: rendered.subject,
605 text: rendered.text,
606 html: rendered.html,
607 attachments: message.attachments,
608 })
609 }
610
611 pub async fn send_test(&self, to: &str) -> CoreResult<()> {
614 self.send_now(Message::template(TEST_TEMPLATE, serde_json::json!({})).to(to))
615 .await
616 }
617}
618
619#[cfg(test)]
620mod tests {
621 use super::*;
622
623 #[test]
624 fn an_address_parses_with_or_without_a_name() {
625 let named = Address::parse("Ada Lovelace <ada@acme.example>").unwrap();
626 assert_eq!(named.name.as_deref(), Some("Ada Lovelace"));
627 assert_eq!(named.email, "ada@acme.example");
628 assert_eq!(named.to_string(), "Ada Lovelace <ada@acme.example>");
629 let bare = Address::parse(" ada@acme.example ").unwrap();
630 assert_eq!(bare.name, None);
631 assert!(Address::parse("nobody").is_none());
632 assert!(Address::parse("a b@c.d").is_none());
633 }
634
635 #[test]
636 fn a_message_round_trips_through_json() {
637 let message = Message::template("acme.blog::welcome", serde_json::json!({"name": "Ada"}))
638 .to("ada@acme.example")
639 .cc("Bob <bob@acme.example>")
640 .attach("a.txt", "text/plain", b"hi".to_vec());
641 let json = serde_json::to_string(&message).unwrap();
642 let back: Message = serde_json::from_str(&json).unwrap();
643 assert_eq!(back, message);
644 assert!(json.contains("aGk="), "bytes travel as base64");
645 }
646}