mail: an outbox that cannot clog, and card mails that stay true

Mails the relay refused ten times kept sent_at NULL and stayed in the
50-row window send_pending reads, so fifty of them stopped every later
mail for good. They are now left out of that query and kept only for
mail-status.

A card mail is checked against its card before it is sent and dropped
when the card has been finished, deleted, re-dated or put on a board
that was switched off. Due-card mails and the digest skip spaces whose
board is off, as the agenda already did. The reminder needs a diary
space to exist.

Mails that were sent or given up on are pruned after 90 days. A card's
key is kept while the card exists, because an overdue card is asked
about every day and the key is what stops a second mail.
This commit is contained in:
nvrl committed 2026-10-20 01:29:49 +02:00
1 parent e1d696701f
commit b1ba63dc61
3 files changed
+191 -4

No files matched your search

+105 -1
View File
@@ -131,10 +131,14 @@ fn retry_due(attempts: i64, last_attempt_at: Option<i64>, 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<usize> {
// 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<usize> {
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<u64> {
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<String> = 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<String> = sqlx::query_scalar("SELECT dedupe_key FROM notifications")
.fetch_all(&db)
.await
.unwrap();
assert_eq!(left, [format!("card_due:{card}:2026-01-01:overdue")]);
}
}
+79 -2
View File
@@ -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::<i64, _>("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<i64>,
done_at: Option<i64>,
has_board: Option<bool>,
}
/// Whether a card mail queued under `key` — `card_due:<id>:<day>:<stage>` —
/// 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<Notifier>) {
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")
}