aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md183
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.