From 57e104e510ef4ab290edb93cfe9b8e272b43120a Mon Sep 17 00:00:00 2001 From: Jean Date: Tue, 14 Jul 2026 00:18:43 +0000 Subject: [PATCH] Email owners before inactivity-expiry drops their account or channel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When [expire] warn_days is set and email is configured, the expiry sweep now emails a heads-up to owners entering the warning window (inactive past the threshold minus the lead time, but not yet expired) for whom an address is on file — the founder's address for a channel. It's a chance to keep the record by simply using it before the deadline. Each idle spell warns at most once: a new AccountExpiryWarned / ChannelExpiryWarned event marks the record, and the next activity stamp clears the mark so a later idle spell warns afresh. The warning email is a first-class api template (fedserv_api::email::expiry_warning), alongside the reset and confirm mails. --- api/src/email.rs | 26 +++++++++++++ config.example.toml | 4 ++ src/config.rs | 7 ++++ src/engine/db.rs | 91 ++++++++++++++++++++++++++++++++++++++++++--- src/engine/mod.rs | 91 ++++++++++++++++++++++++++++++++++++++++++--- src/grpc.rs | 5 ++- src/main.rs | 2 +- 7 files changed, 214 insertions(+), 12 deletions(-) diff --git a/api/src/email.rs b/api/src/email.rs index 216a306..ab213c5 100644 --- a/api/src/email.rs +++ b/api/src/email.rs @@ -69,6 +69,32 @@ pub fn confirm(brand: &str, accent: &str, logo: &str, account: &str, code: &str) } } +// Warn the owner of an account or channel that inactivity will soon expire it. +// `kind` is "account" or "channel", `remaining` a human span ("7 days"). The +// remaining span takes the prominent code slot. +pub fn expiry_warning(brand: &str, accent: &str, logo: &str, kind: &str, name: &str, remaining: &str) -> Mail { + let keep = if kind == "channel" { + "To keep it, have a member join the channel before then. Otherwise it will be removed." + } else { + "To keep it, just identify to it before then. Otherwise it will be removed." + }; + Mail { + subject: format!("Your {kind} {name} is about to expire"), + text: format!( + "Your {kind} {name} has been inactive and will expire in {remaining}.\n{keep}\n" + ), + html: render( + brand, + accent, + logo, + "About to expire", + &format!("Your {kind} {name} has been inactive and will expire in {remaining}."), + remaining, + keep, + ), + } +} + // Escape the few characters that matter inside HTML text so a value can't break // out of the template. fn escape(s: &str) -> String { diff --git a/config.example.toml b/config.example.toml index 35c598a..3204919 100644 --- a/config.example.toml +++ b/config.example.toml @@ -64,3 +64,7 @@ protocol = 1206 # InspIRCd link protocol version (1206 = insp4, 1205 # [expire] # accounts_days = 90 # channels_days = 30 +# warn_days sends the owner a heads-up email this many days before expiry (only +# where an address is on file and [email] is configured); 0 or omitted = no +# warning email. +# warn_days = 7 diff --git a/src/config.rs b/src/config.rs index 168ffb1..df13616 100644 --- a/src/config.rs +++ b/src/config.rs @@ -43,6 +43,9 @@ pub struct Expire { pub accounts_days: u64, #[serde(default)] pub channels_days: u64, + // Days before expiry to email a warning to the owner (0 = no warning email). + #[serde(default)] + pub warn_days: u64, } impl Expire { @@ -53,6 +56,10 @@ impl Expire { pub fn channel_ttl(&self) -> Option { (self.channels_days > 0).then(|| self.channels_days * 86_400) } + // The warning lead time in seconds, or None if warnings are off. + pub fn warn_ttl(&self) -> Option { + (self.warn_days > 0).then(|| self.warn_days * 86_400) + } } // The staff audit feed: notable service actions are announced to this channel diff --git a/src/engine/db.rs b/src/engine/db.rs index 3050735..c2b9f53 100644 --- a/src/engine/db.rs +++ b/src/engine/db.rs @@ -86,6 +86,10 @@ pub struct Account { // Pinned by an operator so inactivity-expiry never drops it. #[serde(default)] pub noexpire: bool, + // Whether an impending-expiry warning email has already been sent (reset on + // the next activity, so each idle spell warns at most once). + #[serde(default)] + pub expiry_warned: bool, } // A requested vhost awaiting approval. @@ -172,6 +176,9 @@ pub enum Event { ChannelUsed { channel: String, ts: u64 }, AccountNoExpire { account: String, on: bool }, ChannelNoExpire { channel: String, on: bool }, + // An impending-expiry warning email was sent; cleared by the next Seen/Used. + AccountExpiryWarned { account: String }, + ChannelExpiryWarned { channel: String }, // Network bans (OperServ AKILL). Global: a ban covers the whole network, so // every node holds the list and re-applies it at burst. AkillAdded { mask: String, setter: String, reason: String, ts: u64, expires: Option }, @@ -214,6 +221,7 @@ impl Event { | Event::NickUngrouped { .. } | Event::AccountSeen { .. } | Event::AccountNoExpire { .. } + | Event::AccountExpiryWarned { .. } | Event::AkillAdded { .. } | Event::AkillRemoved { .. } => Scope::Global, Event::ChannelRegistered { .. } @@ -243,7 +251,8 @@ impl Event { | Event::VhostForbidRemoved { .. } | Event::VhostTemplateSet { .. } | Event::ChannelUsed { .. } - | Event::ChannelNoExpire { .. } => Scope::Local, + | Event::ChannelNoExpire { .. } + | Event::ChannelExpiryWarned { .. } => Scope::Local, } } } @@ -401,6 +410,9 @@ pub struct ChannelInfo { // Pinned by an operator so inactivity-expiry never drops it. #[serde(default)] pub noexpire: bool, + // Whether an impending-expiry warning email has already been sent. + #[serde(default)] + pub expiry_warned: bool, } // A bot auto-response: when a channel line matches `pattern`, the assigned bot @@ -1112,6 +1124,7 @@ impl Db { vhost_request: None, last_seen: now(), noexpire: false, + expiry_warned: false, }; self.log.append(Event::AccountRegistered(Box::new(account.clone()))).map_err(|_| RegError::Internal)?; self.accounts.insert(key(name), account); @@ -1670,6 +1683,62 @@ impl Db { .collect() } + /// Accounts entering the warning window — inactive past `ttl - lead` but not + /// yet past `ttl` — that have an email, aren't pinned or suspended, and + /// haven't already been warned this idle spell. Returns (account, email, + /// seconds until expiry). + pub fn accounts_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64)> { + let floor = ttl.saturating_sub(lead); + self.accounts + .values() + .filter(|a| !a.noexpire && !a.expiry_warned && a.suspension.is_none() && a.email.is_some()) + .filter_map(|a| { + let idle = now.saturating_sub(a.last_seen.max(a.ts)); + (idle > floor && idle <= ttl).then(|| (a.name.clone(), a.email.clone().unwrap_or_default(), ttl - idle)) + }) + .collect() + } + + /// Channels entering the warning window, paired with their founder's email + /// (skipped when the founder has none). Returns (channel, email, seconds left). + pub fn channels_to_warn(&self, now: u64, ttl: u64, lead: u64) -> Vec<(String, String, u64)> { + let floor = ttl.saturating_sub(lead); + self.channels + .values() + .filter(|c| !c.noexpire && !c.expiry_warned && c.suspension.is_none()) + .filter_map(|c| { + let idle = now.saturating_sub(c.last_used.max(c.ts)); + if idle <= floor || idle > ttl { + return None; + } + let email = self.accounts.get(&key(&c.founder)).and_then(|a| a.email.clone())?; + Some((c.name.clone(), email, ttl - idle)) + }) + .collect() + } + + /// Record that an account has been warned about impending expiry. + pub fn mark_account_warned(&mut self, account: &str) { + let k = key(account); + if self.accounts.contains_key(&k) { + let _ = self.log.append(Event::AccountExpiryWarned { account: account.to_string() }); + if let Some(a) = self.accounts.get_mut(&k) { + a.expiry_warned = true; + } + } + } + + /// Record that a channel has been warned about impending expiry. + pub fn mark_channel_warned(&mut self, channel: &str) { + let k = key(channel); + if self.channels.contains_key(&k) { + let _ = self.log.append(Event::ChannelExpiryWarned { channel: channel.to_string() }); + if let Some(c) = self.channels.get_mut(&k) { + c.expiry_warned = true; + } + } + } + /// Add (or refresh) a network ban. Returns whether the mask was newly added. pub fn akill_add(&mut self, mask: &str, setter: &str, reason: &str, expires: Option) -> Result { let fresh = !self.akills.iter().any(|a| a.mask.eq_ignore_ascii_case(mask) && a.expires.is_none_or(|e| e > now())); @@ -1720,7 +1789,7 @@ impl Db { self.log .append(Event::ChannelRegistered { name: name.to_string(), founder: founder.to_string(), ts }) .map_err(|_| ChanError::Internal)?; - self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false }); + self.channels.insert(k, ChannelInfo { name: name.to_string(), founder: founder.to_string(), ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false }); Ok(()) } @@ -2458,7 +2527,7 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { - channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false }); + channels.insert(key(&name), ChannelInfo { name, founder, ts, lock_on: String::new(), lock_off: String::new(), access: Vec::new(), akick: Vec::new(), desc: String::new(), entrymsg: String::new(), settings: ChanSettings::default(), topic: String::new(), suspension: None, assigned_bot: None , kickers: KickerSettings::default() , badwords: Vec::new(), badwords_rev: 0 , triggers: Vec::new(), triggers_rev: 0, last_used: ts, noexpire: false, expiry_warned: false }); } Event::ChannelDropped { name } => { channels.remove(&key(&name)); @@ -2581,11 +2650,13 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { if let Some(a) = accounts.get_mut(&key(&account)) { a.last_seen = a.last_seen.max(ts); // monotonic: never move activity backwards + a.expiry_warned = false; // activity clears the warning so a later idle spell warns afresh } } Event::ChannelUsed { channel, ts } => { if let Some(c) = channels.get_mut(&key(&channel)) { c.last_used = c.last_used.max(ts); + c.expiry_warned = false; } } Event::AccountNoExpire { account, on } => { @@ -2607,6 +2678,16 @@ fn apply(accounts: &mut HashMap, channels: &mut HashMap { akills.retain(|a| !a.mask.eq_ignore_ascii_case(&mask)); } + Event::AccountExpiryWarned { account } => { + if let Some(a) = accounts.get_mut(&key(&account)) { + a.expiry_warned = true; + } + } + Event::ChannelExpiryWarned { channel } => { + if let Some(c) = channels.get_mut(&key(&channel)) { + c.expiry_warned = true; + } + } } } @@ -3048,7 +3129,7 @@ mod tests { fn account_conflict_resolves_deterministically() { let alice = |hash: &str, ts: u64, home: &str| Account { name: "alice".into(), password_hash: hash.into(), email: None, - ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, + ts, home: home.into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: ts, noexpire: false, expiry_warned: false, }; let converge = |first: &Account, second: &Account| { let (mut acc, mut ch, mut gr, mut bo, mut hc, mut ak) = (HashMap::new(), HashMap::new(), HashMap::new(), HashMap::new(), HostConfig::default(), Vec::new()); @@ -3324,7 +3405,7 @@ mod tests { db.register("alice", "pw", None).unwrap(); let bob = Account { name: "bob".into(), password_hash: "x".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, }; let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(Box::new(bob)) }; db.ingest(entry).unwrap(); diff --git a/src/engine/mod.rs b/src/engine/mod.rs index 3fe9126..da73779 100644 --- a/src/engine/mod.rs +++ b/src/engine/mod.rs @@ -127,6 +127,8 @@ pub struct Engine { // Inactivity-expiry thresholds in seconds. None = that kind never expires. account_ttl: Option, channel_ttl: Option, + // How long before expiry to email a warning (seconds). None = no warnings. + expire_warn: Option, } struct VoteState { @@ -201,6 +203,7 @@ impl Engine { log_channel: None, account_ttl: None, channel_ttl: None, + expire_warn: None, } } @@ -259,10 +262,13 @@ impl Engine { self.log_channel = channel; } - // Inactivity-expiry thresholds (seconds); None leaves that kind never expiring. - pub fn set_expiry(&mut self, account_ttl: Option, channel_ttl: Option) { + // Inactivity-expiry thresholds (seconds); None leaves that kind never + // expiring. `warn` is the lead time before expiry to email a warning (None = + // no warning emails). + pub fn set_expiry(&mut self, account_ttl: Option, channel_ttl: Option, warn: Option) { self.account_ttl = account_ttl; self.channel_ttl = channel_ttl; + self.expire_warn = warn; } // Bring the network's bot pseudo-clients in line with the registry: introduce @@ -543,6 +549,24 @@ impl Engine { // cleanup directly over the outbound path (like a takeover cleanup). pub fn expire_sweep(&mut self) { let now = self.now_secs(); + // First, warn owners whose account/channel is approaching expiry and for + // whom we have an email — a chance to keep it before it's dropped. + if let Some(lead) = self.expire_warn.filter(|_| self.db.email_enabled()) { + if let Some(ttl) = self.account_ttl { + for (account, email, left) in self.db.accounts_to_warn(now, ttl, lead) { + let mail = fedserv_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), "account", &account, &human_duration(left)); + self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) }); + self.db.mark_account_warned(&account); + } + } + if let Some(ttl) = self.channel_ttl { + for (channel, email, left) in self.db.channels_to_warn(now, ttl, lead) { + let mail = fedserv_api::email::expiry_warning(self.db.email_brand(), self.db.email_accent(), self.db.email_logo(), "channel", &channel, &human_duration(left)); + self.emit_irc(NetAction::SendEmail { to: email, subject: mail.subject, text: mail.text, html: Some(mail.html) }); + self.db.mark_channel_warned(&channel); + } + } + } if let Some(ttl) = self.account_ttl { for account in self.db.expired_accounts(now, ttl) { // Never expire an operator's account or one still in use. @@ -1471,6 +1495,17 @@ impl Engine { } } +// A rough human span for an expiry deadline in an email ("7 days", "12 hours"). +fn human_duration(secs: u64) -> String { + let plural = |n: u64, unit: &str| format!("{n} {unit}{}", if n == 1 { "" } else { "s" }); + match secs { + s if s >= 86_400 => plural(s / 86_400, "day"), + s if s >= 3_600 => plural(s / 3_600, "hour"), + s if s >= 60 => plural(s / 60, "minute"), + s => plural(s.max(1), "second"), + } +} + // A one-line, human-readable summary of a logged event for the staff audit // feed, or None for events that are private (memos), cosmetic self-service // (greets, auto-join, personal settings), or otherwise not worth surfacing. @@ -1536,7 +1571,8 @@ fn audit_summary(event: &db::Event) -> Option { | VhostRequestCleared { .. } | MemoSent { .. } | MemoRead { .. } | MemoDeleted { .. } | ChannelMlock { .. } | ChannelDescSet { .. } | ChannelEntryMsgSet { .. } | ChannelSettingsSet { .. } | ChannelKickerSet { .. } | ChannelBadwordsSet { .. } | ChannelTriggersSet { .. } - | ChannelTopicSet { .. } | AccountSeen { .. } | ChannelUsed { .. } => return None, + | ChannelTopicSet { .. } | AccountSeen { .. } | ChannelUsed { .. } + | AccountExpiryWarned { .. } | ChannelExpiryWarned { .. } => return None, }; Some(s) } @@ -2096,7 +2132,7 @@ mod tests { // An earlier claim from another node wins and takes the name over. let winner = db::Account { name: "alice".into(), password_hash: "OTHER".into(), email: None, - ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, + ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], greet: String::new(), vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, }; let entry = LogEntry::for_test("peer", 0, 1, db::Event::AccountRegistered(Box::new(winner))); e.gossip_ingest(entry).unwrap(); @@ -3730,7 +3766,7 @@ mod tests { // Jump far past the threshold and sweep. e.now_override = Some(10_000_000_000); - e.set_expiry(Some(100), Some(100)); + e.set_expiry(Some(100), Some(100), None); e.expire_sweep(); // Only the plain, unused, unpinned, session-less records are gone. @@ -3814,6 +3850,51 @@ mod tests { assert!(os(&mut e, "000AAAAAS", "AKILL LIST").iter().any(|a| matches!(a, NetAction::Notice { text, .. } if text.contains("No matching"))), "list now empty"); } + // An account approaching expiry with an email on file is warned once by + // email, isn't dropped while still in the window, and isn't warned twice. + #[test] + fn expiry_warns_by_email_before_dropping() { + let path = std::env::temp_dir().join("fedserv-expwarn.jsonl"); + let _ = std::fs::remove_file(&path); + let mut db = Db::open(&path, "42S"); + db.scram_iterations = 4096; + db.set_email_enabled(true); + db.register("dozer", "password1", Some("dozer@example.test".to_string())).unwrap(); + db.register("nomail", "password1", None).unwrap(); + let mut e = Engine::new(vec![Box::new(NickServ { uid: "42SAAAAAA".into(), guest_nick: "Guest".into(), guest_seq: 0 })], db); + e.set_sid("42S".into()); + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel(); + e.set_irc_out(tx); + + // A very wide warning window that brackets any freshly-registered account. + e.now_override = Some(10_000_000_000); + e.set_expiry(Some(9_900_000_000), None, Some(9_000_000_000)); + e.expire_sweep(); + + // The account with an email got exactly one warning; neither was dropped. + let mut warns = 0; + while let Ok(a) = rx.try_recv() { + if let NetAction::SendEmail { to, subject, .. } = a { + assert_eq!(to, "dozer@example.test"); + assert!(subject.contains("dozer") && subject.contains("expire"), "subject: {subject}"); + warns += 1; + } + } + assert_eq!(warns, 1, "one warning email for the account with an address"); + assert!(e.db.exists("dozer"), "still in the window, not dropped"); + assert!(e.db.exists("nomail"), "no email, no warning, not dropped"); + + // A second sweep in the same window doesn't re-warn. + e.expire_sweep(); + let mut again = 0; + while let Ok(a) = rx.try_recv() { + if matches!(a, NetAction::SendEmail { .. }) { + again += 1; + } + } + assert_eq!(again, 0, "warning isn't repeated while still idle"); + } + // NOEXPIRE is oper-only. #[test] fn noexpire_command_is_oper_gated() { diff --git a/src/grpc.rs b/src/grpc.rs index b318c1e..37f19bc 100644 --- a/src/grpc.rs +++ b/src/grpc.rs @@ -166,7 +166,9 @@ fn to_wire(entry: &LogEntry) -> Option { | Event::AccountNoExpire { .. } | Event::ChannelNoExpire { .. } | Event::AkillAdded { .. } - | Event::AkillRemoved { .. } => return None, + | Event::AkillRemoved { .. } + | Event::AccountExpiryWarned { .. } + | Event::ChannelExpiryWarned { .. } => return None, }; Some(ReplicationEvent { origin: entry.origin().to_string(), seq: entry.seq(), lamport: entry.lamport(), kind: Some(kind) }) } @@ -425,6 +427,7 @@ mod tests { vhost_request: None, last_seen: 111, noexpire: false, + expiry_warned: false, }; let registered = LogEntry::for_test("A", 0, 1, Event::AccountRegistered(Box::new(acct))); let wire = to_wire(®istered).expect("account registration replicates"); diff --git a/src/main.rs b/src/main.rs index 1fd19d8..68ba9df 100644 --- a/src/main.rs +++ b/src/main.rs @@ -113,7 +113,7 @@ async fn main() -> Result<()> { engine.lock().await.set_sid(cfg.server.sid.clone()); engine.lock().await.set_log_channel(cfg.log.as_ref().map(|l| l.channel.clone())); if let Some(expire) = &cfg.expire { - engine.lock().await.set_expiry(expire.account_ttl(), expire.channel_ttl()); + engine.lock().await.set_expiry(expire.account_ttl(), expire.channel_ttl(), expire.warn_ttl()); } if let Some(gossip) = cfg.gossip.clone() {