diff options
Diffstat (limited to 'docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md | 194 |
1 files changed, 194 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..1423fe9 --- /dev/null +++ b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md @@ -0,0 +1,194 @@ +# 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+1>..<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. The lower bound is exclusive (`prev+1`) +so the revision just written, which is inclusive at the top end, is not +re-matched on the next tick. + +`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. A +single account whose count does not validate is skipped for that tick, and the +revision still advances: holding it back would make every later tick re-notify +the successful accounts' whole range. Only a failure to write the state file +itself is non-fatal and leaves the old revision, so the next tick re-notifies +rather than dropping mail. + +## 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 "New Mail" -u normal -t 10000 -b \ + -i "$MAIL_ICON" \ + -h string:x-dunst-stack-tag:mail-<key> \ + -A default,open \ + "<label> (<count>)" "<body>" + +The heading is split to match the running dunst `format`, which renders `%a` +(the app name) bold on the top line and `%s` (the summary) italic below it: +the app name is `New Mail`, the summary is `<label> (<count>)`. + +Body: up to 3 bullet rows of author and subject, each on its own line with a +blank line between, 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. + +`MAIL_ICON` is an absolute path, not an icon name. The running dunst resolves +a themed icon name only through the `icon_path` in `dunstrc`, which holds no +mail icon, so a name renders nothing; an absolute path under the active theme +resolves, as the machine's other notifiers already do. It points at +`${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg`. + +Because the notification carries `-A`, dunst prepends an `(A)` action +indicator when `show_indicators` is on, so `dunstrc` sets +`show_indicators = no`. + +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 actioned 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. With the running dunst's +`mouse_middle_click = do_action`, it is a middle click that invokes it. + +## 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. |
