email: HTML templates and multipart messages

Emails now render an HTML template from templates/email with a plaintext
fallback, sent as multipart/alternative. A configurable brand name shows
in the template. Reset and confirmation mails share one styled base.
This commit is contained in:
Jean Chevronnet 2026-07-12 15:57:34 +00:00
parent fbcf0eaac7
commit 06e9da4dc8
No known key found for this signature in database
10 changed files with 139 additions and 25 deletions

View file

@ -21,6 +21,13 @@ pub struct Email {
// A shell command the message is piped to on stdin, e.g. "sendmail -t" or
// "msmtp -t". Run via `sh -c`, so redirection and pipes work.
pub command: String,
// Display name shown in email templates (header/footer).
#[serde(default = "default_brand")]
pub brand: String,
}
fn default_brand() -> String {
"Network Services".to_string()
}
#[derive(Debug, Deserialize, Clone)]

56
src/email.rs Normal file
View file

@ -0,0 +1,56 @@
// Email content: renders the HTML templates in ../templates/email and pairs each
// with a plaintext fallback. The link layer sends both as multipart/alternative.
pub struct Mail {
pub subject: String,
pub text: String,
pub html: String,
}
const BASE: &str = include_str!("../templates/email/base.html");
fn render(brand: &str, title: &str, message: &str, code: &str, note: &str) -> String {
BASE.replace("{{brand}}", &escape(brand))
.replace("{{title}}", &escape(title))
.replace("{{message}}", &escape(message))
.replace("{{code}}", &escape(code))
.replace("{{note}}", &escape(note))
}
pub fn reset(brand: &str, account: &str, code: &str) -> Mail {
Mail {
subject: format!("Password reset for {account}"),
text: format!(
"Your password reset code for {account} is: {code}\nIt expires in 15 minutes.\nReset with:\n /msg NickServ RESETPASS {account} {code} <newpassword>\n"
),
html: render(
brand,
"Password reset",
&format!("Use this code to reset the password for your account {account}."),
code,
"This code expires in 15 minutes.",
),
}
}
pub fn confirm(brand: &str, account: &str, code: &str) -> Mail {
Mail {
subject: format!("Confirm your {account} registration"),
text: format!(
"Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n"
),
html: render(
brand,
"Confirm your account",
&format!("Welcome! Confirm the email for your account {account} with the code below."),
code,
"This code expires in 15 minutes.",
),
}
}
// Escape the few characters that matter inside HTML text so a value can't break
// out of the template.
fn escape(s: &str) -> String {
s.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;")
}

View file

@ -456,6 +456,8 @@ pub struct Db {
pub(crate) scram_iterations: u32,
// Whether outbound email is configured, so email features can gate themselves.
email_enabled: bool,
// Display name for email templates.
email_brand: String,
// Node-local, non-persisted email codes: account -> (purpose, code, expiry).
codes: HashMap<String, (CodeKind, String, Instant)>,
}
@ -504,7 +506,7 @@ impl Db {
apply(&mut accounts, &mut channels, &mut grouped, event);
}
tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded");
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, codes: HashMap::new() }
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), codes: HashMap::new() }
}
/// Fold an entry authored by another node into the store — the services-side
@ -731,6 +733,15 @@ impl Db {
self.email_enabled = on;
}
/// Display name used in email templates.
pub fn email_brand(&self) -> &str {
&self.email_brand
}
pub fn set_email_brand(&mut self, brand: &str) {
self.email_brand = brand.to_string();
}
/// Issue a fresh emailed code for `account` and purpose, valid for 15 minutes.
pub fn issue_code(&mut self, account: &str, kind: CodeKind) -> String {
let code = gen_code();

View file

@ -548,11 +548,8 @@ impl Engine {
if ok && !self.db.is_verified(account) {
if let (Some(addr), RegReply::NickServ { agent, uid, .. }) = (addr, &reply) {
let code = self.db.issue_code(account, db::CodeKind::Confirm);
out.push(NetAction::SendEmail {
to: addr,
subject: format!("Confirm your {account} registration"),
body: format!("Welcome! Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes."),
});
let mail = crate::email::confirm(self.db.email_brand(), account, &code);
out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) });
out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: "A confirmation code has been emailed to you. Confirm with \x02CONFIRM <code>\x02.".to_string() });
}
}
@ -1237,7 +1234,7 @@ mod tests {
// Request: a code is emailed to the address on file.
let out = to_ns(&mut e, "RESETPASS alice");
let (to, body) = out.iter().find_map(|a| match a {
NetAction::SendEmail { to, body, .. } => Some((to.clone(), body.clone())),
NetAction::SendEmail { to, text, .. } => Some((to.clone(), text.clone())),
_ => None,
}).expect("a reset code is emailed");
assert_eq!(to, "alice@example.org");
@ -1279,7 +1276,7 @@ mod tests {
let out = e.complete_register(&account, Db::derive_credentials(&password, 4096), email, reply);
assert!(!e.db.is_verified("newbie"), "registering with email starts unverified");
let body = out.iter().find_map(|a| match a {
NetAction::SendEmail { body, .. } => Some(body.clone()),
NetAction::SendEmail { text, .. } => Some(text.clone()),
_ => None,
}).expect("a confirm code is emailed");
let code = body.split("CONFIRM ").nth(1).unwrap().split_whitespace().next().unwrap().to_string();

View file

@ -58,8 +58,8 @@ impl ServiceCtx {
}
// Send an email (the link layer pipes it to the configured mail command).
pub fn send_email(&mut self, to: impl Into<String>, subject: impl Into<String>, body: impl Into<String>) {
self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), body: body.into() });
pub fn send_email(&mut self, to: impl Into<String>, subject: impl Into<String>, text: impl Into<String>, html: Option<String>) {
self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), text: text.into(), html });
}
// Hand a password change to the engine to finish: its derivation runs off the

View file

@ -66,8 +66,8 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
action => vec![action],
};
for act in outs {
if let NetAction::SendEmail { to, subject, body } = act {
dispatch_email(&email, to, subject, body);
if let NetAction::SendEmail { to, subject, text, html } = act {
dispatch_email(&email, to, subject, text, html);
continue;
}
for out in proto.serialize(&act) {
@ -80,8 +80,8 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
// A services-initiated action (e.g. a forced logout after a lost
// registration conflict), pushed by the engine from the gossip path.
Some(action) = irc_rx.recv() => {
if let NetAction::SendEmail { to, subject, body } = action {
dispatch_email(&email, to, subject, body);
if let NetAction::SendEmail { to, subject, text, html } = action {
dispatch_email(&email, to, subject, text, html);
} else {
for out in proto.serialize(&action) {
send(&mut write, &out).await?;
@ -96,13 +96,25 @@ pub async fn run(mut proto: Box<dyn Protocol>, engine: Arc<Mutex<Engine>>, addr:
}
// Fire off an email if email is configured: pipe an RFC822 message to the mail
// command on a spawned task, so a slow MTA never stalls the link.
fn dispatch_email(email: &Option<crate::config::Email>, to: String, subject: String, body: String) {
// command on a spawned task, so a slow MTA never stalls the link. With an HTML
// body it's sent as multipart/alternative (HTML + plaintext fallback).
fn dispatch_email(email: &Option<crate::config::Email>, to: String, subject: String, text: String, html: Option<String>) {
let Some(email) = email.clone() else {
return tracing::warn!(%to, "email requested but not configured");
};
tokio::spawn(async move {
let msg = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\n\r\n{body}\r\n", email.from);
let headers = format!("From: {}\r\nTo: {to}\r\nSubject: {subject}\r\nMIME-Version: 1.0\r\n", email.from);
let msg = match html {
Some(html) => {
let b = "fedserv-alt-boundary-x9";
format!(
"{headers}Content-Type: multipart/alternative; boundary=\"{b}\"\r\n\r\n\
--{b}\r\nContent-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n\
--{b}\r\nContent-Type: text/html; charset=utf-8\r\n\r\n{html}\r\n--{b}--\r\n"
)
}
None => format!("{headers}Content-Type: text/plain; charset=utf-8\r\n\r\n{text}\r\n"),
};
let child = tokio::process::Command::new("sh")
.arg("-c")
.arg(&email.command)

View file

@ -1,5 +1,6 @@
// Core lives in src/; pluggable modules live in ../modules/.
mod config;
mod email;
mod engine;
mod gossip;
mod link;
@ -56,6 +57,9 @@ async fn main() -> Result<()> {
db.scram_iterations = cfg.server.scram_iterations;
db.set_outbound(gossip_tx.clone());
db.set_email_enabled(cfg.email.is_some());
if let Some(email) = &cfg.email {
db.set_email_brand(&email.brand);
}
let engine = Arc::new(Mutex::new(Engine::new(services, db)));
// Channel for services-initiated actions to reach the uplink (drained by the link loop).