email: outbound mail subsystem and RESETPASS

Adds a config-driven mailer: a SendEmail action the link layer pipes to
a configured command (sendmail -t, msmtp, ...) off-thread. NickServ
RESETPASS <account> emails a short-lived code to the address on file;
RESETPASS <account> <code> <newpassword> completes the reset. Codes are
node-local; the password change itself federates.
This commit is contained in:
Jean Chevronnet 2026-07-12 15:28:15 +00:00
parent 020e9bd576
commit a2957ffe02
No known key found for this signature in database
12 changed files with 205 additions and 10 deletions

View file

@ -1,9 +1,9 @@
use std::collections::HashMap;
use std::io::Write;
use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::rand_core::{OsRng, RngCore};
use argon2::password_hash::SaltString;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use serde::{Deserialize, Serialize};
@ -443,6 +443,10 @@ pub struct Db {
log: EventLog,
// PBKDF2 cost baked into new SCRAM verifiers; lowered by tests.
pub(crate) scram_iterations: u32,
// Whether outbound email is configured, so email features can gate themselves.
email_enabled: bool,
// Node-local, non-persisted password-reset codes: account -> (code, expiry).
reset_codes: HashMap<String, (String, Instant)>,
}
fn key(name: &str) -> String {
@ -482,7 +486,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 }
Self { accounts, channels, grouped, log, scram_iterations: scram::DEFAULT_ITERATIONS, email_enabled: false, reset_codes: HashMap::new() }
}
/// Fold an entry authored by another node into the store — the services-side
@ -680,6 +684,34 @@ impl Db {
self.accounts.get(&key(account)).map_or(&[], |a| a.certfps.as_slice())
}
/// Whether outbound email is configured.
pub fn email_enabled(&self) -> bool {
self.email_enabled
}
pub fn set_email_enabled(&mut self, on: bool) {
self.email_enabled = on;
}
/// Issue a fresh password-reset code for `account`, valid for 15 minutes.
pub fn issue_reset_code(&mut self, account: &str) -> String {
let code = gen_code();
self.reset_codes.insert(key(account), (code.clone(), Instant::now() + Duration::from_secs(900)));
code
}
/// Consume a reset code for `account`: true if it matches and hasn't expired.
pub fn take_reset_code(&mut self, account: &str, code: &str) -> bool {
let k = key(account);
match self.reset_codes.get(&k) {
Some((c, deadline)) if c == code && *deadline > Instant::now() => {
self.reset_codes.remove(&k);
true
}
_ => false,
}
}
/// Set (or clear) `account`'s email.
pub fn set_email(&mut self, account: &str, email: Option<String>) -> Result<(), RegError> {
let k = key(account);
@ -1062,6 +1094,13 @@ fn glob_match(pattern: &str, text: &str) -> bool {
pi == p.len()
}
// A random 6-digit code for email verification / password reset.
fn gen_code() -> String {
let mut b = [0u8; 4];
OsRng.fill_bytes(&mut b);
format!("{:06}", u32::from_le_bytes(b) % 1_000_000)
}
fn hash_password(password: &str) -> Option<String> {
let salt = SaltString::generate(&mut OsRng);
Argon2::default().hash_password(password.as_bytes(), &salt).ok().map(|h| h.to_string())