use crate::action::{Error, Result};
use crate::ctx::{Ctx, Service};
use crate::i18n::{I18n, Text};
use crate::platform::{Config, Secret};
use crate::render::escape;
use lettre::message::{Mailbox, MultiPart};
use lettre::{AsyncTransport, Message};
use std::collections::VecDeque;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration;
pub const MAX_QUEUED: usize = 10_000;
pub const MAX_ATTEMPTS: u32 = 8;
pub const MAIL_JOB: &str = "zeyn.mail";
#[derive(Clone, Debug)]
pub struct Mail {
to: Vec<String>,
template: String,
locale: Option<String>,
args: Vec<(&'static str, String)>,
}
impl Mail {
pub fn to(address: &str) -> Mail {
Mail {
to: vec![address.to_string()],
template: String::new(),
locale: None,
args: Vec::new(),
}
}
pub fn and(mut self, address: &str) -> Mail {
self.to.push(address.to_string());
self
}
pub fn template(mut self, template: impl Into<String>) -> Mail {
self.template = template.into();
self
}
pub fn locale(mut self, locale: &str) -> Mail {
self.locale = Some(locale.to_string());
self
}
pub fn arg(mut self, name: &'static str, value: impl Into<String>) -> Mail {
self.args.push((name, value.into()));
self
}
pub fn transition(ctx: &Ctx, address: &str, record: &str) -> Result<Mail> {
let moved = ctx
.trail()
.transition
.ok_or(Error::NotFound)?;
let by = ctx
.identity()
.user
.clone()
.unwrap_or_default();
Ok(Mail::to(address)
.template("zeyn.mail.transition")
.locale(ctx.locale())
.arg("record", record)
.arg("move", moved.name)
.arg("from", moved.from)
.arg("to", moved.to)
.arg("by", by)
.arg("comment", moved.comment.unwrap_or_default()))
}
pub fn render(&self, i18n: &I18n, locale: &str) -> (String, String, String) {
let text = |part: &str, html: bool| {
let mut t = Text::key(format!("{}.{part}", self.template));
for (k, v) in &self.args {
t = t.arg(k, if html { escape(v) } else { v.clone() });
}
i18n.text(locale, &t)
};
let subject: String = text("subject", false)
.chars()
.filter(|c| !c.is_control())
.collect();
let plain = text("text", false);
let body = if i18n
.lookup(locale, &format!("{}.html", self.template))
.is_some()
{
text("html", true)
} else {
escape(&plain)
.split("\n\n")
.map(|p| format!("<p>{}</p>", p.replace('\n', "<br>")))
.collect::<String>()
};
let html = format!(
"<!doctype html><html lang=\"{}\"><head><meta charset=\"utf-8\"><title>{}</title></head><body>{body}</body></html>",
escape(locale),
escape(&subject)
);
(subject, plain, html)
}
}
#[derive(Clone)]
enum Transport {
Smtp(lettre::AsyncSmtpTransport<lettre::Tokio1Executor>),
File(std::path::PathBuf),
Log,
}
struct Queued {
message: Message,
attempts: u32,
}
struct Inner {
from: Mailbox,
transport: Transport,
queue: Mutex<VecDeque<Queued>>,
jobs: OnceLock<crate::jobs::Jobs>,
retries: AtomicU32,
backoff_ms: AtomicU64,
}
#[derive(Clone)]
pub struct Mailer(Arc<Inner>);
impl Service for Mailer {}
impl Mailer {
pub fn from_config(config: &Config) -> Result<Mailer> {
let from: Mailbox = config
.require("mail.from")?
.parse()
.map_err(|_| Error::Internal("mail.from is not an e-mail address".into()))?;
let transport = match config
.get("mail.transport")
.unwrap_or("log")
{
"log" => Transport::Log,
"file" => Transport::File(config.require("mail.file.dir")?.into()),
"smtp" => Transport::Smtp(smtp(config)?),
other => {
return Err(Error::Internal(format!(
"mail.transport `{other}` is not smtp, file or log"
)));
}
};
Ok(Mailer::new(from, transport))
}
pub fn to_files(from: &str, dir: impl Into<std::path::PathBuf>) -> Result<Mailer> {
let from = from
.parse()
.map_err(|_| Error::Internal("the sender is not an e-mail address".into()))?;
Ok(Mailer::new(from, Transport::File(dir.into())))
}
fn new(from: Mailbox, transport: Transport) -> Mailer {
Mailer(Arc::new(Inner {
from,
transport,
queue: Mutex::new(VecDeque::new()),
jobs: OnceLock::new(),
retries: AtomicU32::new(5),
backoff_ms: AtomicU64::new(10_000),
}))
}
pub fn retry(self, times: u32, first_delay: Duration) -> Mailer {
self.0
.retries
.store(times, Ordering::Relaxed);
self.0.backoff_ms.store(
first_delay
.as_millis()
.min(u128::from(u64::MAX)) as u64,
Ordering::Relaxed,
);
self
}
pub fn send(&self, ctx: &Ctx, mail: Mail) -> Result<()> {
let locale = mail
.locale
.clone()
.unwrap_or_else(|| ctx.locale().to_string());
let (subject, plain, html) = mail.render(ctx.i18n(), &locale);
let mut builder = Message::builder()
.from(self.0.from.clone())
.subject(subject);
for to in &mail.to {
let mailbox: Mailbox = to
.parse()
.map_err(|_| Error::Internal("a recipient is not an e-mail address".into()))?;
builder = builder.to(mailbox);
}
let message = builder
.multipart(MultiPart::alternative_plain_html(plain, html))
.map_err(|e| Error::Internal(format!("mail: {e}")))?;
{
let mut queue = self
.0
.queue
.lock()
.map_err(|_| Error::Internal("mail queue lock".into()))?;
if queue.len() >= MAX_QUEUED {
return Err(Error::Internal("the mail queue is full".into()));
}
queue.push_back(Queued {
message,
attempts: 0,
});
}
if let Some(jobs) = self.0.jobs.get() {
let _ = jobs.trigger(MAIL_JOB);
}
Ok(())
}
pub fn queued(&self) -> usize {
self.0
.queue
.lock()
.map(|q| q.len())
.unwrap_or(0)
}
pub async fn flush(&self) -> Result<()> {
let batch: Vec<Queued> = self
.0
.queue
.lock()
.map(|mut q| q.drain(..).collect())
.unwrap_or_default();
let mut failed = 0usize;
for mut item in batch {
match self.deliver(&item.message).await {
Ok(()) => {}
Err(e) => {
item.attempts += 1;
if item.attempts >= MAX_ATTEMPTS {
tracing::error!("mail dropped after {} attempts: {e}", item.attempts);
continue;
}
tracing::warn!("mail not sent, will try again: {e}");
failed += 1;
if let Ok(mut q) = self.0.queue.lock() {
q.push_back(item);
}
}
}
}
if failed > 0 {
Err(Error::Internal(format!("{failed} mail(s) not sent")))
} else {
Ok(())
}
}
async fn deliver(&self, message: &Message) -> std::result::Result<(), String> {
match &self.0.transport {
Transport::Smtp(smtp) => smtp
.send(message.clone())
.await
.map(|_| ())
.map_err(|e| e.to_string()),
Transport::File(dir) => {
let file = lettre::AsyncFileTransport::<lettre::Tokio1Executor>::new(dir);
file.send(message.clone())
.await
.map(|_| ())
.map_err(|e| e.to_string())
}
Transport::Log => {
let size = message.formatted().len();
tracing::info!(
"mail (log transport): {} recipient(s), {size} bytes",
message.envelope().to().len()
);
Ok(())
}
}
}
pub(crate) fn attach(&self, jobs: crate::jobs::Jobs) {
let _ = self.0.jobs.set(jobs);
}
pub(crate) fn schedule(&self) -> crate::jobs::Schedule {
let backoff = Duration::from_millis(
self.0
.backoff_ms
.load(Ordering::Relaxed),
);
crate::jobs::Schedule::every(Duration::from_secs(60))
.retry(self.0.retries.load(Ordering::Relaxed), backoff)
}
}
fn smtp(config: &Config) -> Result<lettre::AsyncSmtpTransport<lettre::Tokio1Executor>> {
type T = lettre::AsyncSmtpTransport<lettre::Tokio1Executor>;
let host = config.require("mail.smtp.host")?;
let builder = match config
.get("mail.smtp.tls")
.unwrap_or("starttls")
{
"starttls" => T::starttls_relay(host),
"tls" => T::relay(host),
"none" => Ok(T::builder_dangerous(host)),
other => {
return Err(Error::Internal(format!(
"mail.smtp.tls `{other}` is not starttls, tls or none"
)));
}
}
.map_err(|e| Error::Internal(format!("mail.smtp.host: {e}")))?;
let mut builder = builder.timeout(Some(Duration::from_secs(30)));
if let Some(port) = config.get("mail.smtp.port") {
builder = builder.port(
port.parse()
.map_err(|_| Error::Internal("mail.smtp.port is not a port number".into()))?,
);
}
if let Some(user) = config.get("mail.smtp.user") {
let password: Secret<String> = config.secret("mail.smtp.password")?;
builder = builder.credentials(lettre::transport::smtp::authentication::Credentials::new(
user.to_string(),
password.expose().clone(),
));
}
Ok(builder.build())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn templates_fill_arguments_and_escape_them_in_html() {
let i18n = I18n::new("en")
.catalog("en", r#"{ "m": { "subject": "Hello {name}\r\nBcc: x@y", "text": "Dear {name},\n\nbye", "html": "<p>Dear <b>{name}</b></p>" } }"#)
.unwrap();
let mail = Mail::to("a@b.c")
.template("m")
.arg("name", "<script>alert(1)</script>");
let (subject, plain, html) = mail.render(&i18n, "en");
assert_eq!(
subject, "Hello <script>alert(1)</script>Bcc: x@y",
"no line breaks reach the header"
);
assert_eq!(plain, "Dear <script>alert(1)</script>,\n\nbye");
assert!(
html.contains("<p>Dear <b><script>alert(1)</script></b></p>"),
"{html}"
);
assert!(!html.contains("<script>"));
let (_, _, html) = Mail::to("a@b.c")
.template("none")
.arg("x", "<i>")
.render(&I18n::new("en"), "en");
assert!(html.contains("<body><p>none.text</p></body>"), "{html}");
}
}