Skip to main content

laterite_core/mail/
mod.rs

1//! Mail: messages rendered from templates and delivered by a driver, queued
2//! by default.
3//!
4//! A module registers templates, layouts and partials as files; the
5//! application seeds them into tables at boot, where an admin may edit a
6//! copy. A message names a template and its variables, or carries its own
7//! subject and body; [`Mail::send`] pushes it on the queue and a worker
8//! renders and delivers it, [`Mail::send_now`] does both in place.
9//!
10//! ```ignore
11//! use laterite_core::mail::{Message, MailRegistry};
12//!
13//! fn register(&self, registry: &mut Registry) {
14//!     registry.add_mail_template("acme.blog::welcome", include_str!("../mail/welcome.htm"));
15//! }
16//!
17//! ctx.mail()
18//!     .send(Message::template("acme.blog::welcome", context! { name => "Ada" }).to("ada@acme.example"))
19//!     .await?;
20//! ```
21//!
22//! Drivers: `smtp` (behind the `smtp` feature), `log` (every message lands in
23//! the mail log and nothing leaves), `stub` (kept in memory, for tests), and
24//! any a module registers through [`MailRegistry::add_mail_driver`].
25
26use 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
48/// The module that owns the mail tables, listed among an application's
49/// built-ins.
50pub 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
76/// The template the Mail settings screen sends to check the transport.
77pub const TEST_TEMPLATE: &str = "laterite::test";
78
79/// An address with an optional display name: `Ada <ada@acme.example>` or
80/// `ada@acme.example`.
81#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
82pub struct Address {
83    pub name: Option<String>,
84    pub email: String,
85}
86
87impl Address {
88    /// Parses `Name <address>` or a bare address; `None` when there is no
89    /// `@` in the address part.
90    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/// A file carried by a message.
120#[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/// What a message says: a template and its variables, or a body of its own.
144#[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/// A message to send. Built with [`Message::template`] or [`Message::raw`]
158/// and the recipient methods; stored as JSON when queued.
159#[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    /// A sender other than the configured one.
167    pub from: Option<Address>,
168    pub attachments: Vec<Attachment>,
169}
170
171impl Message {
172    /// A message rendered from the template `code` with `vars`.
173    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    /// A message with its own subject and text body; `html` optional.
181    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    /// Adds a recipient; text that is not an address is dropped.
202    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/// A message rendered and addressed: what a driver delivers.
243#[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/// Delivers envelopes. A driver's failure is the sender's to see: the job
257/// retries it, the Send test shows it.
258#[async_trait]
259pub trait Mailer: Send + Sync {
260    /// The driver's name, as `mail.driver` names it.
261    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/// A driver a module offers under a name, built from the configuration
268/// when `mail.driver` (or the Mail settings) names it.
269#[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
303/// The typed ways to contribute mail: templates, layouts, partials, drivers.
304pub trait MailRegistry {
305    /// A template file under `code` (`acme.blog::welcome`).
306    fn add_mail_template(&mut self, code: &'static str, file: &'static str);
307    /// A layout file under `code` (`default`).
308    fn add_mail_layout(&mut self, code: &'static str, file: &'static str);
309    /// A partial file under `code` (`button`).
310    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
329/// The built-in drivers plus `extra`, resolved by name.
330pub 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
343/// Builds the driver `config.driver` names from `available`.
344pub 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/// How mail goes out: the sender and the development override, beside the
364/// driver.
365#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
366pub struct Sender {
367    pub from: Address,
368    /// Every message goes here instead of its recipients while set.
369    pub to: Option<Address>,
370}
371
372impl Sender {
373    /// The sender `config` names; a malformed `mail.from` is an error.
374    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/// The mail handle. Cheap to clone: every clone is the same mail service.
408#[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/// The job that renders and delivers a queued message.
422#[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    /// Never called: the handler [`Mail::job_reg`] builds runs it, since it
432    /// needs the mail service.
433    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    /// A mail service delivering through `mailer` as `sender`, rendering
448    /// from `templates`, queueing on `queue`. `available` lists the drivers
449    /// [`Mail::reconfigure`] may switch to.
450    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    /// The service `[mail]` configures, over the built-in drivers and
472    /// `extra`.
473    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    /// Replaces the driver and sender, as a saved Mail settings model asks.
487    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    /// Swaps the driver alone (a test's stub).
499    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    /// The names of every driver this service can switch to, sorted.
517    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    /// The templates, to read, edit, preview and reset.
533    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    /// Queues `message`: a worker renders and delivers it. With
550    /// `queue.mode = inline` it goes out here.
551    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    /// Renders and delivers `message` here, returning the driver's error.
561    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    /// Renders `message` into what the driver would deliver, addresses
574    /// resolved; nothing is sent.
575    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    /// Sends the built-in test template to `to` here, returning the
612    /// driver's error: what the Mail settings' Send test does.
613    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}