mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
e1d696701f
commit
b1ba63dc61
3 files changed
+191
-4
No files matched your search
+105
-1
@@ -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
@@ -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")
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user