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:
parent
3a6239bbd9
commit
57e104e510
7 changed files with 214 additions and 12 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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(®istered).expect("account registration replicates");
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue