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!( + "
`) 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)));