diff --git a/modules/nickserv/resetpass.rs b/modules/nickserv/resetpass.rs index 50a0f38..109faa7 100644 --- a/modules/nickserv/resetpass.rs +++ b/modules/nickserv/resetpass.rs @@ -19,11 +19,8 @@ pub fn handle(me: &str, from: &Sender, args: &[&str], ctx: &mut ServiceCtx, db: return; }; let code = db.issue_code(&canonical, CodeKind::Reset); - ctx.send_email( - email, - format!("Password reset for {canonical}"), - format!("Your password reset code for {canonical} is: {code}\nIt expires in 15 minutes. Reset with:\n /msg NickServ RESETPASS {canonical} {code} "), - ); + let mail = crate::email::reset(db.email_brand(), &canonical, &code); + ctx.send_email(email, mail.subject, mail.text, Some(mail.html)); ctx.notice(me, from.uid, format!("A reset code has been emailed to the address on file for \x02{canonical}\x02.")); } (Some(&name), Some(&code), Some(&newpass)) => { diff --git a/modules/protocol/mod.rs b/modules/protocol/mod.rs index b9cc6f7..900a171 100644 --- a/modules/protocol/mod.rs +++ b/modules/protocol/mod.rs @@ -71,9 +71,9 @@ pub enum NetAction { // Internal only: a password change awaiting the same off-thread derivation. // The link layer derives, then calls Engine::complete_password_change. DeferPassword { account: String, password: String, agent: String, uid: String }, - // Internal only: send an email. The link layer pipes it to the configured - // mail command off-thread; never serialized to the ircd. - SendEmail { to: String, subject: String, body: String }, + // Internal only: send an email (plaintext + optional HTML). The link layer + // pipes it to the configured mail command off-thread; never serialized. + SendEmail { to: String, subject: String, text: String, html: Option }, } // How to answer a registration once its credentials have been derived. diff --git a/src/config.rs b/src/config.rs index 252568e..a7d00ae 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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)] diff --git a/src/email.rs b/src/email.rs new file mode 100644 index 0000000..ac3218c --- /dev/null +++ b/src/email.rs @@ -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} \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('&', "&").replace('<', "<").replace('>', ">") +} diff --git a/src/engine/db.rs b/src/engine/db.rs index 128bc91..945dcfb 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -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, } @@ -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(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 4b2a194..b91ebbb 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -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 \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(); diff --git a/src/engine/service.rs b/src/engine/service.rs index 70bf334..f448768 100644 --- a/src/engine/service.rs +++ b/src/engine/service.rs @@ -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, subject: impl Into, body: impl Into) { - self.actions.push(NetAction::SendEmail { to: to.into(), subject: subject.into(), body: body.into() }); + pub fn send_email(&mut self, to: impl Into, subject: impl Into, text: impl Into, html: Option) { + 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 diff --git a/src/link.rs b/src/link.rs index fd4b59b..78475d4 100644 --- a/src/link.rs +++ b/src/link.rs @@ -66,8 +66,8 @@ pub async fn run(mut proto: Box, engine: Arc>, 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, engine: Arc>, 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, engine: Arc>, 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, 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, to: String, subject: String, text: String, html: Option) { 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) diff --git a/src/main.rs b/src/main.rs index 1ec09be..aac71ea 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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). diff --git a/templates/email/base.html b/templates/email/base.html new file mode 100644 index 0000000..e8609da --- /dev/null +++ b/templates/email/base.html @@ -0,0 +1,30 @@ + + + + + +{{title}} + + + + +
+ + + + +
+ {{brand}} +
+

{{title}}

+

{{message}}

+
+ {{code}} +
+

{{note}}

+
+

You are receiving this because your address is registered with {{brand}}. If this wasn't you, you can safely ignore it.

+
+
+ +