diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md | 183 |
1 files changed, 183 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md new file mode 100644 index 0000000..0b4db9b --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md @@ -0,0 +1,183 @@ +# mail-overview: notify on mail arrival + +New mail should announce itself. Today nothing does: `mailsync.sh` syncs and +writes a status file, `waybar-mail.sh` updates a number in the bar, and the +drawer shows detail only when opened. A message that lands while the user is +looking at something else is silent. + +This adds one notification per account per arriving batch, from a new script +in this component. + +## What it is not + +It is not the quickshell notification daemon that would replace dunst. That +remains deferred and is independent of this: notifications here are sent over +the freedesktop DBus spec, so they work with dunst today and keep working +unchanged if the daemon is ever swapped in. Nothing here should wait for it. + +## Shape + +`mail-overview/mail-notify.sh`, bash, sibling to `waybar-mail.sh`, GPLv2 +header like every other source file here. Started from `autostart.lua`, runs +for the whole session. + + resolve db path from `notmuch config get database.path` + guard: db dir missing -> complain on stderr, exit 1 + seed state silently (no startup notification) + while inotifywait -qq -e close_write,moved_to "$db"; do + sleep 0.3 + notify_new + done + complain, exit 1 + +The watch idiom is copied from `waybar-mail.sh` rather than shared. It is +about six lines, and two copies of six lines beat an abstraction spanning a +bar module and a notifier, which have different owners and different +lifetimes. + +**Why a separate process rather than extending `waybar-mail.sh`.** That +script already has the arrival edge, and reusing it would be the shortest +diff. It was rejected because waybar owns that process: a waybar restart or a +`hyprctl reload` would stop mail notifications with nothing reporting it. A +notifier that silently stops is worse than one that costs a second inotify +watch. Running it inside quickshell was also rejected: `FileView` watches +files, not directories, and a watch on a file inside the Xapian directory +dies on commit (the trap already recorded in AGENTS.md), so it would need a +`Process` running `inotifywait` anyway. + +## What counts as new + +A notmuch commit is not the same as new mail. Reading a message in qtmaildir +drops its `unread` tag and commits; so does tagging. Arrival is detected with +notmuch's own revision counter. + + notmuch count --lastmod 'tag:unread and tag:inbox' + +prints three tab-separated fields: count, database UUID, revision. The +revision is field 3. Verified on notmuch 0.39. + +Per tick, if the revision has not advanced, there is nothing to do. Otherwise, +per account: + + notmuch count "tag:unread and tag:inbox and tag:account-<key> and lastmod:<prev>..<cur>" + notmuch search --format=json --limit=3 --sort=newest-first "<same query>" + +Two calls, the same count-plus-preview pair `Accounts.qml` already makes, and +only for accounts whose query is non-empty. The count gives the true N for +"+N more"; the search gives the rows. + +`search --format=json` returns `authors` and `subject` directly, which is all +the body needs. + +**Alternatives rejected.** A per-account count delta is simpler but cannot +name senders, which was the point of the feature. A timestamp watermark using +`date:@<ts>..` is quietly broken: `date:` is the message's Date header, so +backdated mail never notifies and future-dated mail notifies forever. + +## State + +`~/.local/state/mail-notify.lastmod`, alongside `mail-watcher.heartbeat` and +`mailsync.log`. Written by atomic replace (tmpfile then rename), the same +idiom the heartbeat uses. + +It stores the database UUID as well as the revision. notmuch revisions are +only comparable within one database: a rebuilt database restarts the counter, +and a stored revision from the old one would then be meaningless. A UUID +mismatch is treated exactly like a missing file. + +**Missing, unparseable, or UUID-mismatched state seeds silently:** record the +current revision, notify nothing. Without this, a first run has no floor, +`lastmod:0..` matches every unread inbox message ever, and startup is a wall +of popups. Measured here: 101 unread messages across five accounts. + +The same applies to a restart mid-session. Mail that arrived while the script +was down is never notified. This is the right trade: the waybar count is still +correct and the drawer still shows the mail, so nothing is lost except a +popup that would have been stale anyway. + +The new revision is written **after** every account has been processed, so a +failure mid-loop leaves `prev` unchanged and the next tick retries rather than +dropping a batch silently. + +## Accounts + +Parsed from `qtmaildir.conf`, the same file the drawer parses, so adding an +account in qtmaildir makes it notify with no edit here. + +Two details carry over from `Accounts.qml` and are not optional: + +- **The key runs to the closing bracket, not to the first dot.** Real keys + contain dots: a section like `[account.provider-first.last]` maps to the + notmuch tag `account-provider-first.last`. Splitting on the first dot yields + a tag that matches nothing, and an account that never notifies. +- **Walk lines; never match "everything up to the next `[`".** Several + accounts have folders named like `[Gmail]/Bozze`, which ends a section body + before its `label` and makes the account display its raw key. + +The `label` is what the notification summary shows. + +## The notification + +One per account with new mail: + + dunstify -a mail-overview -u normal -t 10000 \ + -h string:x-dunst-stack-tag:mail-<key> \ + -A default,open \ + "<label> · N new" "<body>" + +Body: up to 3 rows of author and subject, then `+N more` when N > 3. Three +matches the drawer's own `--limit=3`, and bounds the height so a 20-message +mailing list burst does not become a wall. + +Normal urgency and an explicit 10s timeout. Deliberately not `-u critical`: +on most dunst configurations critical notifications never expire, which would +leave mail popups stuck on screen. + +**Stack tag per account** means a second batch for the same account replaces +the first rather than stacking, which is what "one notification per account" +has to mean when mail keeps arriving. + +**Click opens qtmaildir.** `-A default,open` plus `-b` makes dunstify block +until the notification is dismissed or clicked and print the action key, so +each notification is launched in a backgrounded subshell that waits and runs +`~/bin/qtmaildir` on `default`. Without backgrounding, the loop would stall +for the full timeout on every account. + +The click cannot open the *account* that was clicked: `qtmaildir` accepts no +command line arguments, and `startup_account` in its config is a static +setting rather than a flag. This is the same limitation the README already +records for the drawer's thread rows, and it is accepted for the same reason. + +`dunstify` rather than `notify-send` because actions need it. Verified +present, and dunst 1.12.2 lists both `actions` and `x-dunst-stack-tag` in +`--capabilities`. If `dunstify` is absent the script falls back to +`notify-send` with no click action, rather than failing: a notification +without a click is still worth having. + +## Failure handling + +The existing rule for this component applies unchanged: **the output is the +test, not the exit status.** notmuch fails two ways and only one is +detectable. A rejected query prints nothing and exits 1; a query Xapian merely +misparses returns a plausible wrong number and exits 0. + +So every count is validated as `^[0-9]+$`. An account whose count does not +validate is skipped for that tick with no notification. The defence against +the second failure mode is that the queries are fixed strings with only the +account key and the two revisions interpolated, never built from anything +else. + +Falling out of the `inotifywait` loop means the watch itself died. The script +says so on stderr and exits non-zero rather than exiting silently, which would +look indistinguishable from no mail arriving. + +## Verification + +The script is runnable standalone, which is what makes this testable without +waiting for real mail: write a `prev` revision a few hundred revisions behind +current into the state file, run one tick, and confirm the per-account +notifications appear with the right counts, that no storm occurs, and that the +state file advances to the current revision. + +That single check is enough. It exercises the parse, the query, the body +construction and the state write together, and it fails if any of them break. |
