diff --git a/README.md b/README.md index 33452dc..79c22ff 100644 --- a/README.md +++ b/README.md @@ -369,7 +369,13 @@ an outbox table under a key that names the occurrence — `reminder:2026-09-18`, `card_due:41:2026-09-20:overdue` — and a tick a minute drains it. A restart cannot resend yesterday's reminder, because the row is already there; a relay that refuses cannot lose one, because the row is still there and is retried with -a widening gap, up to an hour apart. +a widening gap, up to an hour apart. After ten refusals a mail is left for +`mail-status` to show and no longer stands in front of the ones behind it. + +A card mail is checked against its card before it goes: one finished, deleted +or re-dated since it was queued, or on a board that has been switched off, is +dropped unsent. Mails that are done with are forgotten after 90 days. The +reminder needs a diary to be about, and says nothing when there is none. The login mail says *a new browser*, not *a new person*: the device is remembered as a SHA-256 of its `User-Agent`, which is honest about what it can diff --git a/src/mail/mod.rs b/src/mail/mod.rs index 515f7ac..6dffa4e 100644 --- a/src/mail/mod.rs +++ b/src/mail/mod.rs @@ -131,10 +131,14 @@ fn retry_due(attempts: i64, last_attempt_at: Option, now: i64) -> bool { /// Send everything the outbox is holding. Failures are recorded on the row and /// retried later; one bad message does not block the ones behind it. pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result { + // A mail the relay refused for good stays in the table for mail-status to + // show, and out of this query: counted towards the limit, enough of them + // would crowd out every mail queued after them, for ever. let rows = sqlx::query( "SELECT id, subject, body, attempts, last_attempt_at FROM notifications - WHERE sent_at IS NULL ORDER BY created_at LIMIT 50", + WHERE sent_at IS NULL AND attempts < ?1 ORDER BY created_at LIMIT 50", ) + .bind(MAX_ATTEMPTS) .fetch_all(db) .await?; @@ -184,6 +188,31 @@ pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result { Ok(sent) } +/// How long a mail that is done with — sent, or given up on — is kept. +const KEEP_DAYS: i64 = 90; + +/// Forget mails that are done with and can never be asked about again. A key +/// that names a day, a week or an hour is never asked again once that has +/// passed; a card's key stays as long as its card does, because an overdue +/// card is asked about every day it stays overdue. +pub async fn prune(db: &SqlitePool, now: i64) -> Result { + let removed = sqlx::query( + "DELETE FROM notifications + WHERE created_at < ?1 + AND (sent_at IS NOT NULL OR attempts >= ?2) + AND (kind <> 'card_due' OR NOT EXISTS ( + SELECT 1 FROM cards c + WHERE c.id = CAST(substr(dedupe_key, 10, instr(substr(dedupe_key, 10), ':') - 1) AS INTEGER) + ))", + ) + .bind(now - KEEP_DAYS * 86_400) + .bind(MAX_ATTEMPTS) + .execute(db) + .await? + .rows_affected(); + Ok(removed) +} + #[cfg(test)] mod tests { use super::{retry_due, MAX_ATTEMPTS}; @@ -231,4 +260,79 @@ mod tests { .expect("counting works"); assert_eq!(queued, 1); } + + async fn outbox() -> sqlx::SqlitePool { + let db = sqlx::SqlitePool::connect("sqlite::memory:").await.unwrap(); + sqlx::migrate!("./migrations").run(&db).await.unwrap(); + db + } + + #[tokio::test] + async fn mails_given_up_on_do_not_hold_up_the_rest() { + let db = outbox().await; + for n in 0..60 { + super::enqueue(&db, "reminder", &format!("dead:{n}"), "s", "b").await.unwrap(); + } + sqlx::query("UPDATE notifications SET attempts = ?1, last_attempt_at = 0") + .bind(MAX_ATTEMPTS) + .execute(&db) + .await + .unwrap(); + super::enqueue(&db, "reminder", "reminder:2026-10-20", "s", "b").await.unwrap(); + + let next: Vec = sqlx::query_scalar( + "SELECT dedupe_key FROM notifications WHERE sent_at IS NULL AND attempts < ?1 + ORDER BY created_at LIMIT 50", + ) + .bind(MAX_ATTEMPTS) + .fetch_all(&db) + .await + .unwrap(); + assert_eq!(next, ["reminder:2026-10-20"]); + } + + #[tokio::test] + async fn old_mails_go_but_a_live_cards_key_stays() { + let db = outbox().await; + let space: i64 = sqlx::query_scalar( + "INSERT INTO nodes (parent_id, kind, name, slug, has_board, created_at, updated_at) + VALUES (NULL, 'space', 'job', 'job', 1, 0, 0) RETURNING id", + ) + .fetch_one(&db) + .await + .unwrap(); + let list: i64 = sqlx::query_scalar( + "INSERT INTO board_lists (space_id, name, position, created_at) VALUES (?1, 'l', 0, 0) RETURNING id", + ) + .bind(space) + .fetch_one(&db) + .await + .unwrap(); + let card: i64 = sqlx::query_scalar( + "INSERT INTO cards (list_id, title, position, created_at, updated_at) + VALUES (?1, 'c', 0, 0, 0) RETURNING id", + ) + .bind(list) + .fetch_one(&db) + .await + .unwrap(); + for key in [ + "reminder:2026-01-01".to_string(), + format!("card_due:{card}:2026-01-01:overdue"), + "card_due:999999:2026-01-01:overdue".to_string(), + ] { + super::enqueue(&db, "x", &key, "s", "b").await.unwrap(); + } + sqlx::query("UPDATE notifications SET sent_at = 1, created_at = 1, kind = CASE WHEN dedupe_key LIKE 'card_due:%' THEN 'card_due' ELSE 'reminder' END") + .execute(&db) + .await + .unwrap(); + + assert_eq!(super::prune(&db, crate::now()).await.unwrap(), 2); + let left: Vec = sqlx::query_scalar("SELECT dedupe_key FROM notifications") + .fetch_all(&db) + .await + .unwrap(); + assert_eq!(left, [format!("card_due:{card}:2026-01-01:overdue")]); + } } diff --git a/src/notify/mod.rs b/src/notify/mod.rs index 01e7e63..5ad0f3a 100644 --- a/src/notify/mod.rs +++ b/src/notify/mod.rs @@ -54,7 +54,9 @@ impl Notifier { // already in it are the ones that matter most. tracing::error!(error = %format!("{e:#}"), "the notification sweep failed"); } + self.drop_stale_cards().await?; mail::send_pending(&self.db, mailer).await?; + mail::prune(&self.db, crate::now()).await?; Ok(()) } @@ -93,7 +95,11 @@ impl Notifier { /// "You have not written today." Asked once the reminder time has passed, /// and only if the diary space gained no document that local day. async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> { - if diary_written(&self.db, self.config.mail.timezone, today).await? { + let has_diary = sqlx::query("SELECT 1 FROM nodes WHERE role = 'diary'") + .fetch_optional(&self.db) + .await? + .is_some(); + if !has_diary || diary_written(&self.db, self.config.mail.timezone, today).await? { return Ok(()); } @@ -118,6 +124,7 @@ impl Notifier { JOIN board_lists l ON l.id = c.list_id JOIN nodes s ON s.id = l.space_id WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ? + AND s.has_board = 1 ORDER BY c.due_at", ) .bind(end_of_today) @@ -188,7 +195,7 @@ impl Notifier { FROM cards c JOIN board_lists l ON l.id = c.list_id JOIN nodes s ON s.id = l.space_id - WHERE c.done_at >= ? AND c.done_at < ? + WHERE c.done_at >= ? AND c.done_at < ? AND s.has_board = 1 ORDER BY s.position, c.done_at", ) .bind(start) @@ -202,6 +209,7 @@ impl Notifier { JOIN board_lists l ON l.id = c.list_id JOIN nodes s ON s.id = l.space_id WHERE c.done_at IS NULL AND c.due_at IS NOT NULL AND c.due_at < ? + AND s.has_board = 1 ORDER BY c.due_at", ) .bind(end) @@ -330,6 +338,41 @@ impl Notifier { } } + /// A card mail waiting to go out is checked against its card first: one + /// that has since been finished, deleted, re-dated or taken off a board + /// that was switched off is no longer news, and is dropped unsent. + async fn drop_stale_cards(&self) -> Result<()> { + let tz = self.config.mail.timezone; + let waiting = sqlx::query( + "SELECT n.id, n.dedupe_key, c.due_at, c.done_at, s.has_board + FROM notifications n + LEFT JOIN cards c + ON c.id = CAST(substr(n.dedupe_key, 10, instr(substr(n.dedupe_key, 10), ':') - 1) AS INTEGER) + LEFT JOIN board_lists l ON l.id = c.list_id + LEFT JOIN nodes s ON s.id = l.space_id + WHERE n.kind = 'card_due' AND n.sent_at IS NULL", + ) + .fetch_all(&self.db) + .await?; + + for row in waiting { + let key: String = row.get("dedupe_key"); + let card = CardNow { + due_at: row.get("due_at"), + done_at: row.get("done_at"), + has_board: row.get("has_board"), + }; + if !still_due(tz, &key, &card) { + sqlx::query("DELETE FROM notifications WHERE id = ?1") + .bind(row.get::("id")) + .execute(&self.db) + .await?; + tracing::info!(key, "dropped a card mail that is no longer due"); + } + } + Ok(()) + } + async fn enqueue(&self, kind: &str, key: &str, subject: &str, body: &str) -> Result<()> { if mail::enqueue(&self.db, kind, key, subject, body).await? { tracing::info!(kind, key, "queued a notification"); @@ -355,6 +398,22 @@ impl Notifier { } } +/// A card as it stands now, every field `None` when it has been deleted. +struct CardNow { + due_at: Option, + done_at: Option, + has_board: Option, +} + +/// Whether a card mail queued under `key` — `card_due:::` — +/// still says something true about its card. +fn still_due(tz: Tz, key: &str, card: &CardNow) -> bool { + let queued_for = key.split(':').nth(2); + card.done_at.is_none() + && card.has_board == Some(true) + && card.due_at.map(|due| local_day(tz, due).to_string()).as_deref() == queued_for +} + /// Run the notifier for as long as the server lives. pub fn spawn(notifier: Arc) { if !notifier.enabled() { @@ -485,6 +544,24 @@ mod tests { use chrono::NaiveDate; use chrono_tz::Europe::Berlin; + #[test] + fn a_card_mail_is_only_sent_while_it_is_true() { + let tz = chrono_tz::UTC; + let due = chrono::TimeZone::with_ymd_and_hms(&chrono::Utc, 2026, 10, 20, 12, 0, 0).unwrap().timestamp(); + let key = "card_due:7:2026-10-20:due"; + let open = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(true) }; + assert!(super::still_due(tz, key, &open)); + + let finished = super::CardNow { done_at: Some(due), ..open }; + assert!(!super::still_due(tz, key, &finished)); + let moved = super::CardNow { due_at: Some(due + 86_400), done_at: None, has_board: Some(true) }; + assert!(!super::still_due(tz, key, &moved)); + let board_off = super::CardNow { due_at: Some(due), done_at: None, has_board: Some(false) }; + assert!(!super::still_due(tz, key, &board_off)); + let deleted = super::CardNow { due_at: None, done_at: None, has_board: None }; + assert!(!super::still_due(tz, key, &deleted)); + } + fn day(y: i32, m: u32, d: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, d).expect("a real date") }