diff --git a/api/src/email.rs b/api/src/email.rs index 99916ba..cb28058 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -23,20 +23,44 @@ fn brand_mark(brand: &str, accent: &str, logo: &str) -> String { } } -fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str) -> String { +#[allow(clippy::too_many_arguments)] +fn render(brand: &str, accent: &str, logo: &str, title: &str, message: &str, code: &str, note: &str, button: &str) -> String { // `message` carries the account name (attacker-influenceable). Substitute every // OTHER slot first and `{{message}}` LAST, so a message that literally contains // `{{code}}`/`{{note}}` (an account named that) is inserted verbatim rather than - // splicing in the real code. + // splicing in the real code. `button` is trusted markup built below, not escaped. BASE.replace("{{brand_mark}}", &brand_mark(brand, accent, logo)) .replace("{{brand}}", &escape(brand)) .replace("{{accent}}", &escape(accent)) .replace("{{title}}", &escape(title)) .replace("{{code}}", &escape(code)) .replace("{{note}}", &escape(note)) + .replace("{{button}}", button) .replace("{{message}}", &escape(message)) } +// Percent-encode a URL query value (RFC 3986 unreserved set passes through). +fn percent_encode(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for b in s.bytes() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => out.push(b as char), + _ => out.push_str(&format!("%{b:02X}")), + } + } + out +} + +// A one-click call-to-action button for the HTML email. +fn action_button(accent: &str, url: &str, label: &str) -> String { + format!( + "{}", + escape(url), + escape(accent), + escape(label), + ) +} + pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail { let acc = || vec![("account", account.to_string())]; Mail { @@ -54,19 +78,36 @@ pub fn reset(brand: &str, accent: &str, logo: &str, account: &str, code: &str, l &crate::render(lang, "Use this code to reset the password for your account {account}.", &acc()), code, &crate::render(lang, "This code expires in 15 minutes. If you didn't ask to reset it, ignore this email.", &[]), + "", ), } } -pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str, lang: &str) -> Mail { +pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str, confirm_url: &str, lang: &str) -> Mail { let acc = || vec![("account", account.to_string())]; + // A one-click confirm link when a web endpoint is configured, else code-only. + let link = if confirm_url.is_empty() { + String::new() + } else { + format!("{}?account={}&code={}", confirm_url.trim_end_matches('/'), percent_encode(account), percent_encode(code)) + }; + let mut text = crate::render( + lang, + "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n", + &[("account", account.to_string()), ("code", code.to_string())], + ); + if !link.is_empty() { + text.push_str(&crate::render(lang, "Or confirm in one click: {link}", &[("link", link.clone())])); + text.push('\n'); + } + let button = if link.is_empty() { + String::new() + } else { + action_button(accent, &link, &crate::render(lang, "Confirm now", &[])) + }; Mail { subject: crate::render(lang, "Confirm your {account} registration", &acc()), - text: crate::render( - lang, - "Confirm your account {account} with:\n /msg NickServ CONFIRM {code}\nThe code expires in 15 minutes.\n", - &[("account", account.to_string()), ("code", code.to_string())], - ), + text, html: render( brand, accent, @@ -75,6 +116,7 @@ pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str, &crate::render(lang, "Welcome! Confirm the email for your account {account} with the code below.", &acc()), code, &crate::render(lang, "This code expires in 15 minutes.", &[]), + &button, ), } } @@ -130,6 +172,7 @@ pub fn expiry_warning(brand: &str, accent: &str, logo: &str, kind: ExpiryTarget, &crate::render(lang, "Your {word} {name} has been inactive and will expire in {remaining}.", &base()), remaining, &keep, + "", ), } } @@ -139,3 +182,32 @@ pub fn expiry_warning(brand: &str, accent: &str, logo: &str, kind: ExpiryTarget, fn escape(s: &str) -> String { s.replace('&', "&").replace('<', "<").replace('>', ">") } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn confirm_link_present_only_when_url_set() { + let with = confirm("Brand", "#000000", "", "alice", "ABC123", "https://x.net/confirm", "en"); + assert!(with.text.contains("https://x.net/confirm?account=alice&code=ABC123"), "text link: {}", with.text); + assert!(with.html.contains("href=\"https://x.net/confirm?account=alice&code=ABC123\""), "html button href escaped"); + assert!(with.html.contains("Confirm now"), "button label"); + + let none = confirm("Brand", "#000000", "", "alice", "ABC123", "", "en"); + assert!(!none.text.contains("one click"), "no link line: {}", none.text); + assert!(!none.html.contains("Confirm now"), "no button"); + } + + #[test] + fn confirm_link_percent_encodes_the_account() { + let m = confirm("B", "#000000", "", "a[b", "K", "https://x/confirm", "en"); + assert!(m.text.contains("account=a%5Bb&code=K"), "encoded account: {}", m.text); + } + + #[test] + fn confirm_url_trailing_slash_is_trimmed() { + let m = confirm("B", "#000000", "", "z", "K", "https://x/confirm/", "en"); + assert!(m.text.contains("https://x/confirm?account=z"), "trimmed: {}", m.text); + } +} diff --git a/api/templates/email/base.html b/api/templates/email/base.html index c812797..2148f86 100644 --- a/api/templates/email/base.html +++ b/api/templates/email/base.html @@ -28,6 +28,8 @@ + {{button}} +
📋  Copy code
Tap and hold the code to copy it.
diff --git a/lang/de.json b/lang/de.json index 503a0e6..a16d962 100644 --- a/lang/de.json +++ b/lang/de.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 hat dich als Nachfolger von \u0002{chan}\u0002 festgelegt.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 Kanal zu deiner Auto-Join-Liste hinzugefügt.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 Kanäle zu deiner Auto-Join-Liste hinzugefügt.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Unbekannter AJOIN-Befehl \u0002{other}\u0002. Nutze \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 oder \u0002LIST\u0002.", + "Or confirm in one click: {link}": "Oder bestätige mit einem Klick: {link}", + "Confirm now": "Jetzt bestätigen" } \ No newline at end of file diff --git a/lang/es-ar.json b/lang/es-ar.json index 29a0460..61cad85 100644 --- a/lang/es-ar.json +++ b/lang/es-ar.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal agregado a tu lista de auto-unión.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canales agregados a tu lista de auto-unión.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usá \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", + "Or confirm in one click: {link}": "O confirmá con un clic: {link}", + "Confirm now": "Confirmar ahora" } \ No newline at end of file diff --git a/lang/es.json b/lang/es.json index 0bb31cf..d8af926 100644 --- a/lang/es.json +++ b/lang/es.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 te estableció como sucesor de \u0002{chan}\u0002.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal añadido a tu lista de auto-unión.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canales añadidos a tu lista de auto-unión.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconocido \u0002{other}\u0002. Usa \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 o \u0002LIST\u0002.", + "Or confirm in one click: {link}": "O confirma con un clic: {link}", + "Confirm now": "Confirmar ahora" } \ No newline at end of file diff --git a/lang/fr.json b/lang/fr.json index 954f096..c2988c3 100644 --- a/lang/fr.json +++ b/lang/fr.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 vous a défini comme successeur de \u0002{chan}\u0002.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 salon ajouté à votre liste d'auto-join.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 salons ajoutés à votre liste d'auto-join.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Commande AJOIN inconnue \u0002{other}\u0002. Utilisez \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", + "Or confirm in one click: {link}": "Ou confirmez en un clic : {link}", + "Confirm now": "Confirmer maintenant" } \ No newline at end of file diff --git a/lang/pt-br.json b/lang/pt-br.json index de58b44..91ba9cd 100644 --- a/lang/pt-br.json +++ b/lang/pt-br.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu você como sucessor de \u0002{chan}\u0002.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal adicionado à sua lista de auto-entrada.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canais adicionados à sua lista de auto-entrada.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", + "Or confirm in one click: {link}": "Ou confirme com um clique: {link}", + "Confirm now": "Confirmar agora" } \ No newline at end of file diff --git a/lang/pt.json b/lang/pt.json index cdc4687..61b932b 100644 --- a/lang/pt.json +++ b/lang/pt.json @@ -1363,5 +1363,7 @@ "\u0002{by}\u0002 set you as successor of \u0002{chan}\u0002.": "\u0002{by}\u0002 definiu-o como sucessor de \u0002{chan}\u0002.", "Added \u0002{count}\u0002 channel to your auto-join list.": "\u0002{count}\u0002 canal adicionado à sua lista de auto-entrada.", "Added \u0002{count}\u0002 channels to your auto-join list.": "\u0002{count}\u0002 canais adicionados à sua lista de auto-entrada.", - "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002." + "Unknown AJOIN command \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 or \u0002LIST\u0002.": "Comando AJOIN desconhecido \u0002{other}\u0002. Use \u0002ADD\u0002, \u0002ADDALL\u0002, \u0002DEL\u0002 ou \u0002LIST\u0002.", + "Or confirm in one click: {link}": "Ou confirme com um clique: {link}", + "Confirm now": "Confirmar agora" } \ No newline at end of file diff --git a/src/config.rs b/src/config.rs index 9db3ed9..dab4e18 100644 --- a/src/config.rs +++ b/src/config.rs @@ -332,6 +332,12 @@ pub struct Email { // email clients don't render inline SVG or data URIs). #[serde(default)] pub logo: String, + // Optional base URL of a web endpoint that confirms an account from a code, + // e.g. "https://example.net/confirm". When set, confirmation emails include a + // one-click link (`?account=&code=`) alongside the CONFIRM + // command; the endpoint calls the gRPC Confirm RPC. + #[serde(default)] + pub confirm_url: String, } fn default_brand() -> String { diff --git a/src/engine/db/account.rs b/src/engine/db/account.rs index 5ae7994..a4cb2d1 100644 --- a/src/engine/db/account.rs +++ b/src/engine/db/account.rs @@ -247,6 +247,15 @@ impl Db { &self.email_logo } + /// Base URL of the web confirm endpoint (empty = no one-click link in emails). + pub fn email_confirm_url(&self) -> &str { + &self.email_confirm_url + } + + pub fn set_email_confirm_url(&mut self, url: &str) { + self.email_confirm_url = url.to_string(); + } + pub fn set_email_brand(&mut self, brand: &str) { self.email_brand = brand.to_string(); } diff --git a/src/engine/db/mod.rs b/src/engine/db/mod.rs index 5950d3c..fd428a3 100644 --- a/src/engine/db/mod.rs +++ b/src/engine/db/mod.rs @@ -1144,6 +1144,7 @@ pub struct Db { email_brand: String, email_accent: String, email_logo: String, + email_confirm_url: String, // Node-local, non-persisted email codes, keyed by account. codes: HashMap, // Node-local, non-persisted brute-force throttle for password authentication. @@ -1239,7 +1240,7 @@ impl Db { apply(&mut accounts, &mut channels, &mut grouped, &mut bots, &mut host_cfg, &mut net, event); } tracing::info!(accounts = accounts.len(), channels = channels.len(), "account store loaded"); - Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, default_language: "en".to_string(), available_languages: vec!["en".to_string()] } + Self { accounts, channels, grouped, log, extban_enabled: None, notify_exclude: Vec::new(), live_extbans: None, live_chanmodes: None, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, email_brand: "Network Services".to_string(), email_accent: "#4f46e5".to_string(), email_logo: String::new(), email_confirm_url: String::new(), codes: HashMap::new(), auth_fails: HashMap::new(), vhost_req_times: HashMap::new(), report_times: HashMap::new(), bots, host_cfg, net, ignores: Vec::new(), defcon: 5, external_accounts: false, confusable_check: true, default_language: "en".to_string(), available_languages: vec!["en".to_string()] } } /// Fold an entry authored by another node into the store — the services-side diff --git a/src/engine/register.rs b/src/engine/register.rs index c8090ca..e24d849 100644 --- a/src/engine/register.rs +++ b/src/engine/register.rs @@ -95,7 +95,7 @@ impl Engine { if status == AuthorityStatus::Ok && !self.db.is_verified(name) { if let Some(addr) = addr { let code = self.db.issue_code(name, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code, &self.lang_for_account(name)); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), name, &code, self.db.email_confirm_url(), &self.lang_for_account(name)); self.emit_irc(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); } } @@ -279,7 +279,7 @@ impl Engine { } Some((false, Some(addr))) => { let code = self.db.issue_code(&account, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code, &self.lang_for_account(&account)); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), &account, &code, self.db.email_confirm_url(), &self.lang_for_account(&account)); let mut out = resp("verification_required", "VERIFICATION_REQUIRED", "A new confirmation code has been emailed."); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); out @@ -344,7 +344,7 @@ impl Engine { if needs_verify { if let Some(addr) = addr { let code = self.db.issue_code(account, db::CodeKind::Confirm); - let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code, &self.lang_for_account(account)); + let mail = echo_api::email::confirm(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), account, &code, self.db.email_confirm_url(), &self.lang_for_account(account)); out.push(NetAction::SendEmail { to: addr, subject: mail.subject, text: mail.text, html: Some(mail.html) }); if let RegReply::NickServ { agent, uid, .. } = &reply { out.push(NetAction::Notice { from: agent.clone(), to: uid.clone(), text: echo_api::render(&self.lang_for_account(account), "A confirmation code has been emailed to you. Confirm with \x02CONFIRM \x02.", &[]) }); diff --git a/src/main.rs b/src/main.rs index 5fa9ae9..0f52c17 100644 --- a/src/main.rs +++ b/src/main.rs @@ -281,6 +281,7 @@ async fn main() -> Result<()> { db.set_email_brand(&email.brand); db.set_email_accent(&email.accent); db.set_email_logo(&email.logo); + db.set_email_confirm_url(&email.confirm_url); } let engine = Arc::new(Mutex::new(Engine::new(services, db)));