Add a one-click confirmation link to registration emails (via the [email] confirm_url and the gRPC Confirm RPC)
All checks were successful
CI / check (push) Successful in 5m27s

This commit is contained in:
Jean Chevronnet 2026-07-20 20:00:09 +00:00
parent b500698e82
commit 66eb0ea243
No known key found for this signature in database
13 changed files with 121 additions and 18 deletions

View file

@ -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 (`<url>?account=<name>&code=<code>`) alongside the CONFIRM
// command; the endpoint calls the gRPC Confirm RPC.
#[serde(default)]
pub confirm_url: String,
}
fn default_brand() -> String {

View file

@ -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();
}

View file

@ -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<String, PendingCode>,
// 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

View file

@ -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 <code>\x02.", &[]) });

View file

@ -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)));