Email owners before inactivity-expiry drops their account or channel

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:📧:expiry_warning), alongside
the reset and confirm mails.
This commit is contained in:
Jean Chevronnet 2026-07-14 00:18:43 +00:00
parent 3a6239bbd9
commit 57e104e510
No known key found for this signature in database
7 changed files with 214 additions and 12 deletions

View file

@ -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<u64> {
(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<u64> {
(self.warn_days > 0).then(|| self.warn_days * 86_400)
}
}
// The staff audit feed: notable service actions are announced to this channel

View file

@ -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<u64> },
@ -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<u64>) -> Result<bool, RegError> {
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<String, Account>, channels: &mut HashMap<String,
grouped.remove(&key(&nick));
}
Event::ChannelRegistered { name, founder, ts } => {
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<String, Account>, channels: &mut HashMap<String,
Event::AccountSeen { account, ts } => {
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<String, Account>, channels: &mut HashMap<String,
Event::AkillRemoved { mask } => {
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();

View file

@ -127,6 +127,8 @@ pub struct Engine {
// Inactivity-expiry thresholds in seconds. None = that kind never expires.
account_ttl: Option<u64>,
channel_ttl: Option<u64>,
// How long before expiry to email a warning (seconds). None = no warnings.
expire_warn: Option<u64>,
}
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<u64>, channel_ttl: Option<u64>) {
// 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<u64>, channel_ttl: Option<u64>, warn: Option<u64>) {
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<String> {
| 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() {

View file

@ -166,7 +166,9 @@ fn to_wire(entry: &LogEntry) -> Option<ReplicationEvent> {
| 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(&registered).expect("account registration replicates");

View file

@ -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() {