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
@@ -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
|
`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
|
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
|
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
|
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
|
remembered as a SHA-256 of its `User-Agent`, which is honest about what it can
|
||||||
|
|||||||
+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
|
/// 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.
|
/// retried later; one bad message does not block the ones behind it.
|
||||||
pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result<usize> {
|
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(
|
let rows = sqlx::query(
|
||||||
"SELECT id, subject, body, attempts, last_attempt_at FROM notifications
|
"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)
|
.fetch_all(db)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
@@ -184,6 +188,31 @@ pub async fn send_pending(db: &SqlitePool, mailer: &Mailer) -> Result<usize> {
|
|||||||
Ok(sent)
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{retry_due, MAX_ATTEMPTS};
|
use super::{retry_due, MAX_ATTEMPTS};
|
||||||
@@ -231,4 +260,79 @@ mod tests {
|
|||||||
.expect("counting works");
|
.expect("counting works");
|
||||||
assert_eq!(queued, 1);
|
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.
|
// already in it are the ones that matter most.
|
||||||
tracing::error!(error = %format!("{e:#}"), "the notification sweep failed");
|
tracing::error!(error = %format!("{e:#}"), "the notification sweep failed");
|
||||||
}
|
}
|
||||||
|
self.drop_stale_cards().await?;
|
||||||
mail::send_pending(&self.db, mailer).await?;
|
mail::send_pending(&self.db, mailer).await?;
|
||||||
|
mail::prune(&self.db, crate::now()).await?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +95,11 @@ impl Notifier {
|
|||||||
/// "You have not written today." Asked once the reminder time has passed,
|
/// "You have not written today." Asked once the reminder time has passed,
|
||||||
/// and only if the diary space gained no document that local day.
|
/// and only if the diary space gained no document that local day.
|
||||||
async fn sweep_reminder(&self, today: NaiveDate) -> Result<()> {
|
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(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,6 +124,7 @@ impl Notifier {
|
|||||||
JOIN board_lists l ON l.id = c.list_id
|
JOIN board_lists l ON l.id = c.list_id
|
||||||
JOIN nodes s ON s.id = l.space_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 < ?
|
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",
|
ORDER BY c.due_at",
|
||||||
)
|
)
|
||||||
.bind(end_of_today)
|
.bind(end_of_today)
|
||||||
@@ -188,7 +195,7 @@ impl Notifier {
|
|||||||
FROM cards c
|
FROM cards c
|
||||||
JOIN board_lists l ON l.id = c.list_id
|
JOIN board_lists l ON l.id = c.list_id
|
||||||
JOIN nodes s ON s.id = l.space_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",
|
ORDER BY s.position, c.done_at",
|
||||||
)
|
)
|
||||||
.bind(start)
|
.bind(start)
|
||||||
@@ -202,6 +209,7 @@ impl Notifier {
|
|||||||
JOIN board_lists l ON l.id = c.list_id
|
JOIN board_lists l ON l.id = c.list_id
|
||||||
JOIN nodes s ON s.id = l.space_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 < ?
|
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",
|
ORDER BY c.due_at",
|
||||||
)
|
)
|
||||||
.bind(end)
|
.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<()> {
|
async fn enqueue(&self, kind: &str, key: &str, subject: &str, body: &str) -> Result<()> {
|
||||||
if mail::enqueue(&self.db, kind, key, subject, body).await? {
|
if mail::enqueue(&self.db, kind, key, subject, body).await? {
|
||||||
tracing::info!(kind, key, "queued a notification");
|
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.
|
/// Run the notifier for as long as the server lives.
|
||||||
pub fn spawn(notifier: Arc<Notifier>) {
|
pub fn spawn(notifier: Arc<Notifier>) {
|
||||||
if !notifier.enabled() {
|
if !notifier.enabled() {
|
||||||
@@ -485,6 +544,24 @@ mod tests {
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use chrono_tz::Europe::Berlin;
|
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 {
|
fn day(y: i32, m: u32, d: u32) -> NaiveDate {
|
||||||
NaiveDate::from_ymd_opt(y, m, d).expect("a real date")
|
NaiveDate::from_ymd_opt(y, m, d).expect("a real date")
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user