aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-13 17:03:37 +0200
committerDanilo M. <danix@danix.xyz>2026-09-13 17:03:37 +0200
commit1d4bb87c03b2ccec6555ace002c5a519abb86f74 (patch)
tree23f32d68e5ee8472d885de14c01b4753e67e723d /docs/superpowers/specs
parent9fc6ca0d9a9cdba9d5808951ce290e559a9e8554 (diff)
downloadquickshell-1d4bb87c03b2ccec6555ace002c5a519abb86f74.tar.gz
quickshell-1d4bb87c03b2ccec6555ace002c5a519abb86f74.zip
docs: design for mail arrival notifications
One notification per account per arriving batch, from a new mail-notify.sh sibling to waybar-mail.sh. Arrival is detected with notmuch's lastmod revision counter rather than a count delta or a date watermark: a commit is not the same as new mail, since reading and tagging also commit, and date: is the message's own Date header, so backdated mail would never notify and future-dated mail would notify forever. A separate process rather than extending waybar-mail.sh, which already has the arrival edge: waybar owns that process, so a bar restart would stop notifications with nothing reporting it. State carries the database UUID beside the revision, because notmuch revisions are only comparable within one database. Missing, corrupt or mismatched state seeds silently: with no floor, lastmod:0.. matches all 101 unread messages here and startup becomes a wall of popups. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ZwWrCEbdKzmisfg9bb1nS
Diffstat (limited to 'docs/superpowers/specs')
-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.