Skip to main content

renox_core/
mail.rs

1//! Sending email: SMTP in production, the log or memory in development and
2//! tests, HTML templates with a text version, and a preview page.
3//!
4//! ```
5//! # use renox::prelude::*;
6//! # async fn demo(state: AppState, order: renox::serde_json::Value) -> Result {
7//! let mail = state.mail_view("ben@example.com", "Your receipt", "mail/receipt", context! { order })?;
8//! state.mailer.send(mail.clone()).await?;   // now
9//! state.queue_mail(mail).await?;            // through the queue, with retries
10//! # Ok(()) }
11//! ```
12//!
13//! `MAIL_MAILER` picks the driver: `smtp` (`MAIL_HOST`, `MAIL_PORT`,
14//! `MAIL_USERNAME`, `MAIL_PASSWORD`, `MAIL_ENCRYPTION` = `tls`, `starttls` or
15//! `none`), `log` (the default: messages go to the server log) or `memory`
16//! (for tests: `kernel.mailer().sent()`). With `APP_DEBUG` on, the last 50
17//! messages are listed at `/_renox/mail`.
18
19use std::collections::VecDeque;
20use std::sync::{Arc, Mutex};
21
22use anyhow::Context;
23use axum::Router;
24use axum::extract::{Path, State};
25use axum::response::Html;
26use axum::routing::get;
27use lettre::message::header::ContentType;
28use lettre::message::{Mailbox, MultiPart};
29use lettre::transport::smtp::authentication::Credentials;
30use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
31use serde::{Deserialize, Serialize};
32
33use crate::db::{DateTime, now};
34use crate::queue::{Job, JobContext};
35use crate::{AppState, Config, Error, Result, context};
36
37/// How many sent messages the preview page keeps.
38const OUTBOX: usize = 50;
39
40/// One email message. `html` is optional; `text` is always sent.
41///
42/// ```
43/// # use renox::mail::Mail;
44/// let pdf: Vec<u8> = b"%PDF-1.7 ...".to_vec();
45/// let mail = Mail::new("ben@example.com", "Invoice INV-001", "Your invoice is attached.")
46///     .also_to("finance@example.com")
47///     .cc("sales@example.com")
48///     .bcc("archive@example.com")
49///     .reply_to("Coffee Shop <hello@shop.example>")
50///     .from("Coffee Shop Billing <billing@shop.example>") // instead of MAIL_FROM_*
51///     .attach("INV-001.pdf", "application/pdf", pdf);
52/// ```
53///
54/// Addresses are `a@b.c` or `Name <a@b.c>`; an invalid one fails the send
55/// (and a queued mail isn't retried for it).
56#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
57#[non_exhaustive]
58pub struct Mail {
59    /// Recipients.
60    pub to: Vec<String>,
61    /// The subject line.
62    pub subject: String,
63    /// The plain-text body.
64    pub text: String,
65    /// An HTML body, sent next to `text` when set.
66    #[serde(default)]
67    pub html: Option<String>,
68    /// Carbon-copy recipients.
69    #[serde(default)]
70    pub cc: Vec<String>,
71    /// Blind-carbon-copy recipients.
72    #[serde(default)]
73    pub bcc: Vec<String>,
74    /// The address replies go to.
75    #[serde(default)]
76    pub reply_to: Option<String>,
77    /// Sender instead of `MAIL_FROM_ADDRESS` / `MAIL_FROM_NAME`.
78    #[serde(default)]
79    pub from: Option<String>,
80    /// Files sent with the mail.
81    #[serde(default)]
82    pub attachments: Vec<Attachment>,
83}
84
85/// A file sent with a mail.
86#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
87#[non_exhaustive]
88pub struct Attachment {
89    /// The file name the recipient sees, e.g. `INV-001.pdf`.
90    pub filename: String,
91    /// e.g. `application/pdf`.
92    pub content_type: String,
93    /// Stored as base64 when the mail is queued.
94    #[serde(with = "base64_bytes")]
95    pub data: Vec<u8>,
96}
97
98mod base64_bytes {
99    use base64::Engine;
100    use base64::engine::general_purpose::STANDARD;
101    use serde::{Deserialize, Deserializer, Serializer};
102
103    pub fn serialize<S: Serializer>(data: &[u8], s: S) -> Result<S::Ok, S::Error> {
104        s.serialize_str(&STANDARD.encode(data))
105    }
106
107    pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Vec<u8>, D::Error> {
108        let text = String::deserialize(d)?;
109        STANDARD.decode(text).map_err(serde::de::Error::custom)
110    }
111}
112
113impl Mail {
114    /// A mail to `to` with this subject and plain-text body.
115    pub fn new(to: impl Into<String>, subject: impl Into<String>, text: impl Into<String>) -> Self {
116        Self {
117            to: vec![to.into()],
118            subject: subject.into(),
119            text: text.into(),
120            html: None,
121            cc: Vec::new(),
122            bcc: Vec::new(),
123            reply_to: None,
124            from: None,
125            attachments: Vec::new(),
126        }
127    }
128
129    /// Adds an HTML body; `text` is still sent as the plain alternative.
130    pub fn html(mut self, html: impl Into<String>) -> Self {
131        self.html = Some(html.into());
132        self
133    }
134
135    /// Another recipient.
136    pub fn also_to(mut self, address: impl Into<String>) -> Self {
137        self.to.push(address.into());
138        self
139    }
140
141    /// Adds a carbon-copy recipient.
142    pub fn cc(mut self, address: impl Into<String>) -> Self {
143        self.cc.push(address.into());
144        self
145    }
146
147    /// Adds a blind-carbon-copy recipient.
148    pub fn bcc(mut self, address: impl Into<String>) -> Self {
149        self.bcc.push(address.into());
150        self
151    }
152
153    /// Where replies go.
154    pub fn reply_to(mut self, address: impl Into<String>) -> Self {
155        self.reply_to = Some(address.into());
156        self
157    }
158
159    /// Sends as `address` instead of `MAIL_FROM_ADDRESS` / `MAIL_FROM_NAME`.
160    pub fn from(mut self, address: impl Into<String>) -> Self {
161        self.from = Some(address.into());
162        self
163    }
164
165    /// Attaches `data` as a file named `filename` of type `content_type`.
166    pub fn attach(
167        mut self,
168        filename: impl Into<String>,
169        content_type: impl Into<String>,
170        data: impl Into<Vec<u8>>,
171    ) -> Self {
172        self.attachments.push(Attachment {
173            filename: filename.into(),
174            content_type: content_type.into(),
175            data: data.into(),
176        });
177        self
178    }
179
180    /// Whether `address` is among `to`, `cc` or `bcc`.
181    pub fn is_for(&self, address: &str) -> bool {
182        self.to
183            .iter()
184            .chain(&self.cc)
185            .chain(&self.bcc)
186            .any(|a| a == address || a.ends_with(&format!("<{address}>")))
187    }
188}
189
190setting_enum! {
191    /// How mail is sent, from `MAIL_MAILER`.
192    pub enum MailDriver ("MAIL_MAILER") {
193        /// Through an SMTP server (`MAIL_HOST`); use it in production.
194        Smtp = "smtp",
195        /// Written to the server log; nothing leaves the machine (the default).
196        Log = "log",
197        /// Kept in memory, for tests (`app.sent_mail()`).
198        Memory = "memory",
199    }
200}
201
202setting_enum! {
203    /// How the SMTP connection is secured, from `MAIL_ENCRYPTION`.
204    pub enum MailEncryption ("MAIL_ENCRYPTION") {
205        /// TLS from the start (usually port 465).
206        Tls = "tls",
207        /// Plain, upgraded with STARTTLS (usually port 587; the default).
208        StartTls = "starttls",
209        /// Not encrypted: a local test server such as Mailpit only.
210        None = "none",
211    }
212}
213
214/// Mail settings, from `MAIL_*`.
215#[derive(Clone)]
216#[non_exhaustive]
217pub struct MailConfig {
218    /// How mail is sent: SMTP, the log or memory.
219    pub mailer: MailDriver,
220    /// SMTP server, from `MAIL_HOST` (default `localhost`).
221    pub host: String,
222    /// SMTP port, from `MAIL_PORT`; `None` uses the encryption's usual port.
223    pub port: Option<u16>,
224    /// SMTP user name, from `MAIL_USERNAME`.
225    pub username: Option<String>,
226    /// SMTP password, from `MAIL_PASSWORD`.
227    pub password: Option<String>,
228    /// `tls` (usually port 465), `starttls` (587) or `none` (e.g. Mailpit on 1025).
229    pub encryption: MailEncryption,
230    /// Sender address, from `MAIL_FROM_ADDRESS` (default `hello@example.com`).
231    pub from_address: String,
232    /// Sender name, from `MAIL_FROM_NAME`.
233    pub from_name: Option<String>,
234    /// How long sending one mail over SMTP may take, from `MAIL_TIMEOUT` in
235    /// seconds (default 10).
236    pub timeout: std::time::Duration,
237    /// Mailers (`App::mailer` names) to try in order when this one fails,
238    /// from `MAIL_FAILOVER` (comma-separated), e.g. `backup` for a second
239    /// SMTP provider.
240    pub failover: Vec<String>,
241}
242
243impl std::fmt::Debug for MailConfig {
244    // Secrets show as `[hidden]`, so a logged config doesn't leak them.
245    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
246        f.debug_struct("MailConfig")
247            .field("mailer", &self.mailer)
248            .field("host", &self.host)
249            .field("port", &self.port)
250            .field("username", &self.username)
251            .field("password", &self.password.as_ref().map(|_| "[hidden]"))
252            .field("encryption", &self.encryption)
253            .field("from_address", &self.from_address)
254            .field("from_name", &self.from_name)
255            .field("timeout", &self.timeout)
256            .field("failover", &self.failover)
257            .finish()
258    }
259}
260
261impl Default for MailConfig {
262    fn default() -> Self {
263        Self {
264            mailer: MailDriver::Log,
265            host: "localhost".into(),
266            port: None,
267            username: None,
268            password: None,
269            encryption: MailEncryption::StartTls,
270            from_address: "hello@example.com".into(),
271            timeout: std::time::Duration::from_secs(10),
272            from_name: None,
273            failover: Vec::new(),
274        }
275    }
276}
277
278impl MailConfig {
279    /// A mailer's settings from variables starting with `prefix`, for
280    /// [`App::mailer`](crate::App::mailer): `<PREFIX>_MAILER` (`smtp`,
281    /// `log` or `memory`; default: the app's `MAIL_MAILER`, so it logs
282    /// while developing and keeps mail in memory in tests), `<PREFIX>_HOST`, `_PORT`,
283    /// `_USERNAME`, `_PASSWORD`, `_ENCRYPTION`, `_TIMEOUT` (seconds), and
284    /// `<PREFIX>_FROM_ADDRESS` / `_FROM_NAME`, which fall back to the
285    /// app's `MAIL_FROM_*`. A value that doesn't fit (an unknown driver, a
286    /// port that isn't a number) is an error naming the variable, and the
287    /// app doesn't boot.
288    pub fn from_env(config: &Config, prefix: &str) -> crate::Result<Self> {
289        let prefix = prefix.trim_end_matches('_').to_ascii_uppercase();
290        let name = |name: &str| format!("{prefix}_{name}");
291        let var = |key: &str| config.var(&name(key));
292        let number = |key: &str| -> anyhow::Result<Option<u64>> {
293            var(key)
294                .map(|value| {
295                    value.trim().parse::<u64>().map_err(|_| {
296                        anyhow::anyhow!("{} must be a number, got `{value}`", name(key))
297                    })
298                })
299                .transpose()
300        };
301        let defaults = Self::default();
302        let port = number("PORT")?
303            .map(|p| {
304                u16::try_from(p).map_err(|_| anyhow::anyhow!("{} is not a port", name("PORT")))
305            })
306            .transpose()?;
307        Ok(Self {
308            mailer: var("MAILER")
309                .map(|v| MailDriver::parse_as(&v, &name("MAILER")))
310                .transpose()?
311                .unwrap_or(config.mail.mailer),
312            host: var("HOST").unwrap_or(defaults.host),
313            port,
314            username: var("USERNAME"),
315            password: var("PASSWORD"),
316            encryption: var("ENCRYPTION")
317                .map(|v| MailEncryption::parse_as(&v, &name("ENCRYPTION")))
318                .transpose()?
319                .unwrap_or(defaults.encryption),
320            from_address: var("FROM_ADDRESS").unwrap_or_else(|| config.mail.from_address.clone()),
321            from_name: var("FROM_NAME").or_else(|| config.mail.from_name.clone()),
322            timeout: number("TIMEOUT")?.map_or(defaults.timeout, std::time::Duration::from_secs),
323            failover: Vec::new(),
324        })
325    }
326}
327
328#[derive(Clone)]
329enum Driver {
330    Log,
331    Memory,
332    Smtp {
333        transport: AsyncSmtpTransport<Tokio1Executor>,
334        from: Mailbox,
335        timeout: std::time::Duration,
336    },
337}
338
339#[derive(Debug, Clone, Serialize)]
340struct Sent {
341    id: u64,
342    at: DateTime,
343    mail: Mail,
344}
345
346/// Sends mail with the configured driver and remembers recent messages for
347/// the preview page and tests.
348#[derive(Clone)]
349pub struct Mailer {
350    driver: Driver,
351    /// Mailers tried in order when `driver` fails (`MAIL_FAILOVER`).
352    failover: Vec<(String, Driver)>,
353    outbox: Arc<Mutex<(u64, VecDeque<Sent>)>>,
354    /// The memory driver keeps everything; others keep the last `OUTBOX` in debug.
355    keep: Option<usize>,
356}
357
358impl Mailer {
359    pub(crate) fn from_config(config: &Config) -> anyhow::Result<Self> {
360        Self::open(&config.mail, config)
361    }
362
363    /// A mailer from `mail`.
364    pub(crate) fn open(mail: &MailConfig, config: &Config) -> anyhow::Result<Self> {
365        let (driver, keep) = match mail.mailer {
366            MailDriver::Log => (Driver::Log, config.debug.then_some(OUTBOX)),
367            MailDriver::Memory => (Driver::Memory, Some(usize::MAX)),
368            MailDriver::Smtp => (smtp(mail, &config.name)?, config.debug.then_some(OUTBOX)),
369        };
370        Ok(Self {
371            driver,
372            failover: Vec::new(),
373            outbox: Arc::default(),
374            keep,
375        })
376    }
377
378    /// Tries `others` in order when this mailer fails.
379    pub(crate) fn with_failover(mut self, others: Vec<(String, Mailer)>) -> Self {
380        self.failover = others
381            .into_iter()
382            .map(|(name, mailer)| (name, mailer.driver))
383            .collect();
384        self
385    }
386
387    /// Sends `mail` through the configured driver (the `log` and `memory`
388    /// drivers only record it). When it fails and `MAIL_FAILOVER` names
389    /// other mailers, they're tried in order; a mail that can't be sent at
390    /// all (a bad address) isn't handed on.
391    pub async fn send(&self, mail: Mail) -> Result {
392        if let Err(mut last) = deliver(&self.driver, &mail).await {
393            if last.is_permanent() || self.failover.is_empty() {
394                return Err(last);
395            }
396            let mut sent = false;
397            for (name, driver) in &self.failover {
398                tracing::warn!(error = ?last, mailer = %name, "sending mail failed; trying the next mailer");
399                match deliver(driver, &mail).await {
400                    Ok(()) => {
401                        sent = true;
402                        break;
403                    }
404                    Err(err) => last = err,
405                }
406            }
407            if !sent {
408                return Err(last);
409            }
410        }
411        self.remember(mail);
412        Ok(())
413    }
414
415    fn remember(&self, mail: Mail) {
416        let Some(keep) = self.keep else { return };
417        let mut outbox = self.outbox.lock().unwrap_or_else(|e| e.into_inner());
418        outbox.0 += 1;
419        let id = outbox.0;
420        outbox.1.push_back(Sent {
421            id,
422            at: now(),
423            mail,
424        });
425        while outbox.1.len() > keep {
426            outbox.1.pop_front();
427        }
428    }
429
430    /// Messages sent so far that the mailer kept: all of them with the
431    /// `memory` driver, the last 50 with `APP_DEBUG` on.
432    pub fn sent(&self) -> Vec<Mail> {
433        let outbox = self.outbox.lock().unwrap_or_else(|e| e.into_inner());
434        outbox.1.iter().map(|s| s.mail.clone()).collect()
435    }
436
437    fn kept(&self) -> Vec<Sent> {
438        self.outbox
439            .lock()
440            .unwrap_or_else(|e| e.into_inner())
441            .1
442            .iter()
443            .cloned()
444            .collect()
445    }
446}
447
448/// Hands `mail` to one driver.
449async fn deliver(driver: &Driver, mail: &Mail) -> Result {
450    match driver {
451        Driver::Log => tracing::info!(
452            "mail (log driver)\nTo: {}\nSubject: {}\n\n{}\n",
453            mail.to.join(", "),
454            mail.subject,
455            mail.text
456        ),
457        Driver::Memory => {}
458        Driver::Smtp {
459            transport,
460            from,
461            timeout,
462        } => {
463            let message = message(from, mail)?;
464            // lettre's own timeout doesn't cover a server that accepts the
465            // connection and then says nothing.
466            tokio::time::timeout(*timeout, transport.send(message))
467                .await
468                .map_err(|_| anyhow::anyhow!("no answer from the SMTP server in {timeout:?}"))
469                .and_then(|sent| sent.map_err(anyhow::Error::from))
470                .with_context(|| format!("sending mail to {}", mail.to.join(", ")))?;
471        }
472    }
473    Ok(())
474}
475
476fn smtp(mail: &MailConfig, app_name: &str) -> anyhow::Result<Driver> {
477    let mut builder = match mail.encryption {
478        MailEncryption::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&mail.host)?,
479        MailEncryption::StartTls => {
480            AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&mail.host)?
481        }
482        MailEncryption::None => AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&mail.host),
483    };
484    if let Some(port) = mail.port {
485        builder = builder.port(port);
486    }
487    builder = builder.timeout(Some(mail.timeout));
488    if let (Some(user), Some(password)) = (&mail.username, &mail.password) {
489        builder = builder.credentials(Credentials::new(user.clone(), password.clone()));
490    }
491    let name = mail
492        .from_name
493        .clone()
494        .unwrap_or_else(|| app_name.to_owned());
495    let address = mail.from_address.parse().with_context(|| {
496        format!(
497            "MAIL_FROM_ADDRESS `{}` is not an email address",
498            mail.from_address
499        )
500    })?;
501    Ok(Driver::Smtp {
502        transport: builder.build(),
503        from: Mailbox::new(Some(name), address),
504        timeout: mail.timeout,
505    })
506}
507
508fn mailbox(address: &str) -> Result<Mailbox> {
509    address.trim().parse().map_err(|err| {
510        Error::permanent(
511            anyhow::Error::new(err).context(format!("`{address}` is not an email address")),
512        )
513    })
514}
515
516fn message(from: &Mailbox, mail: &Mail) -> Result<Message> {
517    use lettre::message::{Attachment as Part, SinglePart};
518
519    if mail.to.is_empty() {
520        return Err(Error::permanent(anyhow::anyhow!(
521            "the mail has no recipient"
522        )));
523    }
524    let from = match &mail.from {
525        Some(address) => mailbox(address)?,
526        None => from.clone(),
527    };
528    let mut builder = Message::builder().from(from).subject(mail.subject.clone());
529    for address in &mail.to {
530        builder = builder.to(mailbox(address)?);
531    }
532    for address in &mail.cc {
533        builder = builder.cc(mailbox(address)?);
534    }
535    for address in &mail.bcc {
536        builder = builder.bcc(mailbox(address)?);
537    }
538    if let Some(address) = &mail.reply_to {
539        builder = builder.reply_to(mailbox(address)?);
540    }
541    let body = match &mail.html {
542        Some(html) => MultiPart::alternative_plain_html(mail.text.clone(), html.clone()),
543        None => MultiPart::mixed().singlepart(
544            SinglePart::builder()
545                .header(ContentType::TEXT_PLAIN)
546                .body(mail.text.clone()),
547        ),
548    };
549    let message = if mail.attachments.is_empty() {
550        match &mail.html {
551            Some(_) => builder.multipart(body),
552            None => builder
553                .header(ContentType::TEXT_PLAIN)
554                .body(mail.text.clone()),
555        }
556    } else {
557        let mut mixed = MultiPart::mixed().multipart(body);
558        for file in &mail.attachments {
559            let content_type = ContentType::parse(&file.content_type).map_err(|err| {
560                Error::permanent(anyhow::anyhow!(
561                    "attachment `{}`: `{}` is not a content type ({err})",
562                    file.filename,
563                    file.content_type
564                ))
565            })?;
566            mixed = mixed
567                .singlepart(Part::new(file.filename.clone()).body(file.data.clone(), content_type));
568        }
569        builder.multipart(mixed)
570    };
571    Ok(message.map_err(anyhow::Error::from)?)
572}
573
574/// Sends a mail from the queue; see `AppState::queue_mail`.
575#[derive(Serialize, Deserialize)]
576pub(crate) struct SendMail(pub Mail);
577
578impl Job for SendMail {
579    const NAME: &'static str = "renox.send-mail";
580    const MAX_ATTEMPTS: u32 = 5;
581
582    async fn handle(self, ctx: JobContext) -> Result {
583        ctx.state.mailer.send(self.0).await
584    }
585}
586
587/// Sends a mail from the queue through a named mailer; see
588/// `AppState::queue_mail_via`.
589#[derive(Serialize, Deserialize)]
590pub(crate) struct SendMailVia {
591    pub mailer: String,
592    pub mail: Mail,
593}
594
595impl Job for SendMailVia {
596    const NAME: &'static str = "renox.send-mail-via";
597    const MAX_ATTEMPTS: u32 = 5;
598
599    async fn handle(self, ctx: JobContext) -> Result {
600        // A mailer removed since the mail was queued won't come back.
601        let mailer = ctx
602            .state
603            .mailer_named(&self.mailer)
604            .map_err(|err| Error::permanent(anyhow::anyhow!("{err:?}")))?;
605        mailer.send(self.mail).await
606    }
607}
608
609impl AppState {
610    /// Renders `{view}.html` into a mail, with `{view}.txt` as the text
611    /// version when it exists (otherwise text made from the HTML).
612    /// Templates see `app` (`app.locale` too) and `t(key, …)` in the
613    /// [`current_locale`](crate::i18n::current_locale) besides `ctx`, can
614    /// extend `renox/mail/layout.html` and import
615    /// `renox/mail/components.html` (`button`, `panel`, `table`).
616    pub fn mail_view(
617        &self,
618        to: impl Into<String>,
619        subject: impl Into<String>,
620        view: &str,
621        ctx: impl Serialize,
622    ) -> Result<Mail> {
623        let locale = crate::i18n::current_locale(self);
624        let ctx = minijinja::value::merge_maps([
625            minijinja::Value::from_serialize(&ctx),
626            context! {
627                app => context! { name => self.config.name, url => self.config.url, locale => locale },
628                t => crate::view::translate_function(self, &locale),
629            },
630        ]);
631        let html = self.views.render(&format!("{view}.html"), &ctx)?;
632        let text = match self.views.render(&format!("{view}.txt"), &ctx) {
633            Ok(text) => text,
634            Err(err) if is_not_found(&err) => html_to_text(&html),
635            Err(err) => return Err(err.into()),
636        };
637        Ok(Mail::new(to, subject, text.trim().to_owned()).html(html))
638    }
639
640    /// `mail_view` in `locale` (the recipient's language).
641    pub fn mail_view_in(
642        &self,
643        locale: &str,
644        to: impl Into<String>,
645        subject: impl Into<String>,
646        view: &str,
647        ctx: impl Serialize,
648    ) -> Result<Mail> {
649        crate::i18n::with_locale(Some(locale), || self.mail_view(to, subject, view, ctx))
650    }
651
652    /// Sends `mail` from a queue worker, retrying up to five times.
653    pub async fn queue_mail(&self, mail: Mail) -> Result<i64> {
654        self.dispatch(SendMail(mail)).await
655    }
656
657    /// A mailer the app added with [`App::mailer`](crate::App::mailer), e.g.
658    /// `state.mailer_named("newsletter")?.send(mail)`; an unknown name is an
659    /// error (500).
660    pub fn mailer_named(&self, name: &str) -> Result<&Mailer> {
661        self.mailers.get(name).ok_or_else(|| {
662            anyhow::anyhow!("no mailer named `{name}`: add it with `App::mailer(\"{name}\", …)`")
663                .into()
664        })
665    }
666
667    /// Like `queue_mail`, through the mailer named `mailer`.
668    pub async fn queue_mail_via(&self, mailer: &str, mail: Mail) -> Result<i64> {
669        self.mailer_named(mailer)?;
670        self.dispatch(SendMailVia {
671            mailer: mailer.to_owned(),
672            mail,
673        })
674        .await
675    }
676}
677
678fn is_not_found(err: &anyhow::Error) -> bool {
679    err.downcast_ref::<minijinja::Error>()
680        .is_some_and(|e| e.kind() == minijinja::ErrorKind::TemplateNotFound)
681}
682
683/// A readable text version of an HTML mail: tags dropped, blocks separated
684/// by blank lines, table cells by spaces, links written as `text (url)`.
685pub(crate) fn html_to_text(html: &str) -> String {
686    let body = html
687        .find("<body")
688        .and_then(|start| html[start..].find('>').map(|end| start + end + 1))
689        .map_or(html, |start| &html[start..]);
690    let mut out = String::new();
691    let mut href: Option<String> = None;
692    let mut rest = body;
693    while let Some(open) = rest.find('<') {
694        out.push_str(&rest[..open]);
695        let Some(close) = rest[open..].find('>') else {
696            // A `<` that opens no tag: the rest is text (once).
697            rest = &rest[open..];
698            break;
699        };
700        let tag = &rest[open + 1..open + close];
701        let name = tag
702            .trim_start_matches('/')
703            .split(|c: char| c.is_whitespace() || c == '/')
704            .next()
705            .unwrap_or_default()
706            .to_ascii_lowercase();
707        match name.as_str() {
708            "a" if !tag.starts_with('/') => {
709                href = tag
710                    .split("href=\"")
711                    .nth(1)
712                    .and_then(|v| v.split('"').next())
713                    .map(str::to_owned);
714            }
715            "a" => {
716                if let Some(url) = href.take() {
717                    out.push_str(&format!(" ({url})"));
718                }
719            }
720            "style" | "head" | "title" if !tag.starts_with('/') => {
721                let end = format!("</{name}");
722                if let Some(skip) = rest[open..].to_ascii_lowercase().find(&end) {
723                    rest = &rest[open + skip..];
724                    continue;
725                }
726            }
727            "br" | "p" | "div" | "tr" | "li" | "h1" | "h2" | "h3" | "table" => out.push('\n'),
728            "td" | "th" => out.push(' '),
729            _ => {}
730        }
731        rest = &rest[open + close + 1..];
732    }
733    out.push_str(rest);
734    let decoded = out
735        .replace("&nbsp;", " ")
736        .replace("&lt;", "<")
737        .replace("&gt;", ">")
738        .replace("&quot;", "\"")
739        .replace("&#39;", "'")
740        .replace("&#x27;", "'")
741        .replace("&#x2f;", "/")
742        .replace("&amp;", "&");
743    let mut text = String::new();
744    let mut blank = false;
745    for line in decoded
746        .lines()
747        .map(|l| l.split_whitespace().collect::<Vec<_>>().join(" "))
748    {
749        if line.is_empty() {
750            blank = !text.is_empty();
751            continue;
752        }
753        if blank {
754            text.push('\n');
755            blank = false;
756        }
757        text.push_str(&line);
758        text.push('\n');
759    }
760    text
761}
762
763/// `/_renox/mail`: recently sent messages, in development only.
764pub(crate) fn preview_router() -> Router<AppState> {
765    Router::new()
766        .route("/_renox/mail", get(list))
767        .route("/_renox/mail/{id}", get(show))
768}
769
770fn escape(s: &str) -> String {
771    s.replace('&', "&amp;")
772        .replace('<', "&lt;")
773        .replace('>', "&gt;")
774        .replace('"', "&quot;")
775}
776
777const STYLE: &str = "<style>body{font-family:system-ui,sans-serif;max-width:60rem;margin:2rem auto;padding:0 1rem;color:#222}\
778table{width:100%;border-collapse:collapse}td{padding:.5rem;border-bottom:1px solid #eee}\
779iframe{width:100%;height:32rem;border:1px solid #ddd;border-radius:.5rem}pre{white-space:pre-wrap;background:#f6f6f6;padding:1rem}</style>";
780
781async fn list(State(state): State<AppState>) -> Html<String> {
782    let rows: String = state
783        .mailer
784        .kept()
785        .iter()
786        .rev()
787        .map(|s| {
788            format!(
789                "<tr><td>{}</td><td><a href=\"/_renox/mail/{}\">{}</a></td><td>{}</td></tr>",
790                s.at.format("%H:%M:%S"),
791                s.id,
792                escape(&s.mail.subject),
793                escape(&s.mail.to.join(", "))
794            )
795        })
796        .collect();
797    let rows = if rows.is_empty() {
798        "<tr><td>No mail sent yet.</td></tr>".to_owned()
799    } else {
800        rows
801    };
802    Html(format!(
803        "<!doctype html><meta charset=utf-8><title>Mail · Renox</title>{STYLE}<h1>Sent mail</h1><table>{rows}</table>"
804    ))
805}
806
807async fn show(State(state): State<AppState>, Path(id): Path<u64>) -> Result<Html<String>> {
808    let sent = state
809        .mailer
810        .kept()
811        .into_iter()
812        .find(|s| s.id == id)
813        .ok_or(Error::NotFound)?;
814    let html = sent.mail.html.as_deref().map_or(String::new(), |html| {
815        format!(
816            "<h2>HTML</h2><iframe sandbox srcdoc=\"{}\"></iframe>",
817            escape(html)
818        )
819    });
820    let mut details = format!("<p>To: {}</p>", escape(&sent.mail.to.join(", ")));
821    for (label, list) in [("Cc", &sent.mail.cc), ("Bcc", &sent.mail.bcc)] {
822        if !list.is_empty() {
823            details.push_str(&format!("<p>{label}: {}</p>", escape(&list.join(", "))));
824        }
825    }
826    for (label, value) in [("Reply-To", &sent.mail.reply_to), ("From", &sent.mail.from)] {
827        if let Some(value) = value {
828            details.push_str(&format!("<p>{label}: {}</p>", escape(value)));
829        }
830    }
831    if !sent.mail.attachments.is_empty() {
832        let files: Vec<String> = sent
833            .mail
834            .attachments
835            .iter()
836            .map(|a| {
837                format!(
838                    "{} ({}, {} bytes)",
839                    escape(&a.filename),
840                    escape(&a.content_type),
841                    a.data.len()
842                )
843            })
844            .collect();
845        details.push_str(&format!("<p>Attachments: {}</p>", files.join(", ")));
846    }
847    Ok(Html(format!(
848        "<!doctype html><meta charset=utf-8><title>{subject} · Renox</title>{STYLE}\
849         <p><a href=\"/_renox/mail\">&larr; All mail</a></p><h1>{subject}</h1>{details}{html}\
850         <h2>Text</h2><pre>{text}</pre>",
851        subject = escape(&sent.mail.subject),
852        text = escape(&sent.mail.text),
853    )))
854}
855
856#[cfg(test)]
857mod tests {
858    use super::*;
859
860    #[test]
861    fn html_becomes_readable_text() {
862        let html = r#"<html><head><style>p{color:red}</style></head><body>
863            <h1>Hello &amp; welcome</h1><p>Click <a href="https://x.id/a?b=1&amp;c=2">here</a>.</p>
864            <table><tr><td>Total</td><td>$10.00</td></tr></table></body></html>"#;
865        assert_eq!(
866            html_to_text(html),
867            "Hello & welcome\n\nClick here (https://x.id/a?b=1&c=2).\n\nTotal $10.00\n"
868        );
869        // Without a <body>: a style block is skipped, and a title that is
870        // never closed is dropped as a tag.
871        assert_eq!(html_to_text("<style>p{}</style>Hi<p>there"), "Hi\nthere\n");
872        assert_eq!(html_to_text("<title>No end"), "No end\n");
873        // A `<` that opens no tag is text, kept once (it was written twice).
874        assert_eq!(html_to_text("Price < 5"), "Price < 5\n");
875        assert_eq!(html_to_text("<p>a</p>b <c"), "a\nb <c\n");
876    }
877
878    /// The log driver writes the mail to the log; with `APP_DEBUG` on it
879    /// keeps the last 50 for `/_renox/mail`, and none with it off.
880    #[tokio::test]
881    async fn the_log_driver_keeps_the_last_fifty_while_debugging() {
882        let mut config = Config {
883            debug: true,
884            ..Config::default()
885        };
886        let mail = MailConfig::default();
887        let mailer = Mailer::open(&mail, &config).unwrap();
888        for i in 0..OUTBOX + 1 {
889            mailer
890                .send(Mail::new("ann@example.com", format!("Mail {i}"), "Hi"))
891                .await
892                .unwrap();
893        }
894        let sent = mailer.sent();
895        assert_eq!(sent.len(), OUTBOX);
896        assert_eq!(sent[0].subject, "Mail 1", "the oldest is gone");
897
898        config.debug = false;
899        let quiet = Mailer::open(&mail, &config).unwrap();
900        quiet
901            .send(Mail::new("ann@example.com", "Hi", "Hi"))
902            .await
903            .unwrap();
904        assert!(quiet.sent().is_empty());
905    }
906
907    /// Mails SMTP can't send are permanent errors (no retry), found before
908    /// connecting: no recipient, an attachment with a bad content type.
909    #[test]
910    fn mails_smtp_cant_send_are_permanent_errors() {
911        let from: Mailbox = "shop@example.com".parse().unwrap();
912        let mut nobody = Mail::new("ann@example.com", "Hi", "Hi");
913        nobody.to.clear();
914        let err = message(&from, &nobody).unwrap_err();
915        assert!(err.is_permanent());
916        assert!(format!("{err:?}").contains("the mail has no recipient"));
917
918        let odd = Mail::new("ann@example.com", "Hi", "Hi").attach("a.bin", "not a type", vec![1]);
919        let err = message(&from, &odd).unwrap_err();
920        assert!(err.is_permanent());
921        assert!(
922            format!("{err:?}").contains("is not a content type"),
923            "{err:?}"
924        );
925    }
926
927    #[tokio::test]
928    async fn the_outbox_page_says_when_nothing_was_sent() {
929        let app = crate::testing::TestApp::new(crate::App::new()).await;
930        app.get("/_renox/mail")
931            .await
932            .assert_ok()
933            .assert_see("No mail sent yet.");
934    }
935}