aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers')
-rw-r--r--docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md924
-rw-r--r--docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md183
2 files changed, 1107 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md
new file mode 100644
index 0000000..02f35f8
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md
@@ -0,0 +1,924 @@
+# Mail Arrival Notifications Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** One desktop notification per mail account per arriving batch, naming the senders and subjects, triggered by notmuch database commits.
+
+**Architecture:** A standalone bash script, `mail-overview/mail-notify.sh`, watches the notmuch Xapian directory with `inotifywait` and on each commit asks notmuch what changed since the last revision it saw. It notifies per account via `dunstify`, storing its position in a small state file. It runs as its own process from `autostart.lua`, not inside quickshell and not inside the waybar module.
+
+**Tech Stack:** bash, notmuch 0.39 (`--lastmod`, `lastmod:` queries), `inotifywait` (inotify-tools), `dunstify` (dunst 1.12.2), `jq`.
+
+**Design spec:** `docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md`
+
+---
+
+## Testing approach, and why it is not a framework
+
+This repo has no test framework and should not gain one. Per the project and
+global conventions, non-trivial logic leaves behind **one runnable check**: a
+single bash script, `mail-overview/test-mail-notify.sh`, asserting against
+fixture data. No pytest, no fixtures directory, no harness.
+
+The check is real TDD: each task writes its assertion first, watches it fail,
+then implements. The assertions run against **pure functions** that take text
+on stdin or as arguments and print to stdout. That is the single most
+important structural decision in this plan: **no function that this test
+touches may call notmuch, dunstify, or inotify.** Those are named in one
+place each, at the edges, so everything with logic in it stays testable
+without mail arriving.
+
+`set -u` everywhere, matching `waybar-mail.sh`. Not `set -e`: this script must
+survive a failing notmuch call and carry on to the next account, which is the
+same reasoning `Accounts.qml` documents for its per-account loop.
+
+---
+
+## File Structure
+
+| File | Responsibility |
+| --- | --- |
+| `mail-overview/mail-notify.sh` (create) | Everything: parse accounts, read/write state, query notmuch, build bodies, send notifications, watch loop. One file, ~180 lines. |
+| `mail-overview/test-mail-notify.sh` (create) | The one runnable check. Sources the script with `MAIL_NOTIFY_LIB=1` and asserts on the pure functions. |
+| `mail-overview/README.md` (modify) | A section documenting the notifier, matching the existing prose style. |
+| `~/.config/hypr/sections/autostart.lua` (modify, outside repo) | One `hl.exec_cmd` line. Not committed; this repo holds no home paths. |
+
+**Why one script rather than several.** It is a single sequential job with no
+reusable parts: an abstraction between "parse config" and "send notification"
+would have exactly one caller each. The project convention is single-file
+scripts where practical, and `waybar-mail.sh` sets the precedent at 69 lines.
+
+**The library guard.** The script ends with a `main` dispatch that only runs
+when `MAIL_NOTIFY_LIB` is unset, so the test can source it for its functions
+without starting a watch loop. This is the standard bash equivalent of
+Python's `if __name__ == "__main__"`.
+
+---
+
+## Task 1: Skeleton, library guard, and the test harness
+
+Creates both files, establishes the sourcing contract, and proves the test
+runs. No mail logic yet.
+
+**Files:**
+- Create: `mail-overview/mail-notify.sh`
+- Create: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Create `mail-overview/test-mail-notify.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for mail-notify.sh. No framework: it sources the
+# script as a library and asserts on the pure functions, the ones that take
+# text and print text. Nothing here calls notmuch, dunstify or inotify, which
+# is why it runs in milliseconds and needs no mail to arrive.
+#
+# Usage: ./test-mail-notify.sh (exit 0 = all passed)
+
+set -u
+
+MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh"
+
+pass=0
+fail=0
+
+# Compares two strings and reports. Multi-line values are printed with their
+# newlines intact, because half the assertions here are about line structure.
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got"
+ fi
+}
+
+check "library guard does not run main" "loaded" "loaded"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+chmod +x mail-overview/test-mail-notify.sh
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL, `mail-notify.sh: No such file or directory`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Create `mail-overview/mail-notify.sh`:
+
+```bash
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Notifies when mail arrives: one notification per account per batch, naming
+# the newest senders and subjects.
+#
+# Watches the notmuch Xapian directory and, on each commit, asks notmuch what
+# changed since the revision it last saw. A commit is NOT the same as new
+# mail, since reading and tagging also commit, which is why the revision
+# counter does the work rather than a count delta.
+#
+# This is its own process rather than part of waybar-mail.sh, which already
+# has the same arrival edge: waybar owns that process, so a bar restart would
+# stop notifications with nothing reporting it.
+#
+# Usage:
+# mail-notify.sh watch forever (what autostart runs)
+# mail-notify.sh --once process one tick and exit (what the test drives)
+
+set -u
+
+STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
+CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
+SCOPE='tag:unread and tag:inbox'
+
+# How many threads a notification body lists before eliding into "+N more".
+# Three matches the drawer's own --limit=3.
+ROWS=3
+
+main() {
+ echo "not implemented"
+}
+
+# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
+# loop. The bash equivalent of Python's __name__ == "__main__".
+[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+chmod +x mail-overview/mail-notify.sh
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `1 passed, 0 failed`, exit 0. Nothing prints `not implemented`,
+which is the proof the guard works.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): skeleton for the arrival notifier
+
+A library guard so the test can source the script for its pure
+functions without starting a watch loop, and the check itself,
+which is one bash script rather than a framework."
+```
+
+---
+
+## Task 2: Parse accounts from qtmaildir.conf
+
+The key-to-bracket and walk-lines traps both live here. They are the two
+failure modes that already cost this component a debugging session each, per
+`Accounts.qml` and the README.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh`, immediately before the `printf '\n%d passed'`
+summary line:
+
+```bash
+# A config with the two traps in it: a key containing dots, and a folder
+# value containing a bracket. Both are real shapes from qtmaildir.conf, with
+# placeholder names.
+read -r -d '' fixture <<'EOF'
+[general]
+theme = dark
+
+[account.simple]
+label = Simple
+color = #112233
+
+[account.provider-first.last]
+folder = [Gmail]/Bozze
+label = Dotted
+color = #445566
+
+[account.nolabel]
+color = #778899
+
+[ui]
+label = NotAnAccount
+EOF
+
+check "keys run to the bracket, not the first dot" \
+ "simple provider-first.last nolabel" \
+ "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')"
+
+check "a bracket in a value does not end the section" \
+ "Dotted" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')"
+
+check "a missing label falls back to the key" \
+ "nolabel" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')"
+
+check "a non-account section is not an account" \
+ "" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL on all four, `parse_accounts: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh`, after the `ROWS=3` line:
+
+```bash
+# Accounts in file order, one "key<TAB>label" line each, read from stdin.
+#
+# Two things here are load-bearing, and both have already broken this
+# component once:
+#
+# The key runs to the closing bracket, NOT to the first dot. Real keys
+# contain dots, so splitting on the first one yields a notmuch tag matching
+# nothing and an account that silently never notifies.
+#
+# And this walks lines rather than matching a section body as "everything up
+# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends
+# the body before its label and makes the account display its raw key.
+parse_accounts() {
+ local line key label
+ key=""
+ label=""
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key="${BASH_REMATCH[1]}"
+ label=""
+ continue
+ fi
+ # Any other section ends the current account.
+ if [[ "$line" =~ ^\[ ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key=""
+ label=""
+ continue
+ fi
+ [[ -n "$key" ]] || continue
+ if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ label="${BASH_REMATCH[1]}"
+ # Trailing whitespace only; a label may contain spaces.
+ label="${label%"${label##*[![:space:]]}"}"
+ fi
+ done
+
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ return 0
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `5 passed, 0 failed`.
+
+- [ ] **Step 5: Verify against the real config**
+
+```bash
+MAIL_NOTIFY_LIB=1 . ./mail-overview/mail-notify.sh
+parse_accounts < ~/.config/qtmaildir/qtmaildir.conf
+```
+
+Expected: five lines, each `key<TAB>label`, every label a real label and none
+falling back to a raw key. Confirms the fixture matches reality.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): parse accounts for the notifier
+
+Same source the drawer parses, so adding an account in qtmaildir
+notifies with no edit here. Keys run to the closing bracket because
+real ones contain dots, and parsing walks lines because a folder
+named [Gmail]/Bozze ends a section body early."
+```
+
+---
+
+## Task 3: Build the notification body
+
+Pure text transformation, the part most worth testing and the part that never
+touches notmuch.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh` before the summary:
+
+```bash
+# The shape notmuch search --format=json actually returns, trimmed to the two
+# fields the body uses.
+rows_json='[
+ {"authors":"Alice Example","subject":"First subject"},
+ {"authors":"Bob Example","subject":"Second subject"},
+ {"authors":"Carol Example","subject":"Third subject"}
+]'
+
+check "a body lists author and subject per row" \
+ "Alice Example — First subject
+Bob Example — Second subject
+Carol Example — Third subject" \
+ "$(build_body "$rows_json" 3)"
+
+check "a batch bigger than the rows shown is elided" \
+ "Alice Example — First subject
+Bob Example — Second subject
+Carol Example — Third subject
++7 more" \
+ "$(build_body "$rows_json" 10)"
+
+check "markup characters are escaped, not rendered" \
+ "A &amp; B — &lt;script&gt;" \
+ "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
+
+check "a missing subject says so rather than printing nothing" \
+ "Alice Example — (no subject)" \
+ "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
+
+check "malformed json yields an empty body rather than an error" \
+ "" \
+ "$(build_body 'not json at all' 1 2>/dev/null)"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL on all five, `build_body: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh` after `parse_accounts`:
+
+```bash
+# Renders notmuch search JSON into notification body text.
+# $1 the JSON array from `notmuch search --format=json`
+# $2 the true total for this batch, which may exceed the rows present
+#
+# dunst has body-markup in its capabilities, so a subject containing < or &
+# would be parsed as markup and could vanish from the notification. Subjects
+# are attacker-controlled text arriving from the internet, so the three XML
+# characters are escaped here. This is the one place in this script where
+# untrusted text reaches a renderer.
+#
+# Malformed JSON prints nothing and succeeds. A notification with no body is
+# still worth sending: the summary already carries the account and the count.
+build_body() {
+ local json="$1" total="$2" shown
+
+ local body
+ body="$(printf '%s' "$json" | jq -r '
+ .[] | ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
+ | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
+ ' 2>/dev/null)" || return 0
+ [[ -n "$body" ]] || return 0
+
+ shown="$(printf '%s\n' "$body" | wc -l)"
+ printf '%s' "$body"
+ if [[ "$total" -gt "$shown" ]]; then
+ printf '\n+%d more' "$((total - shown))"
+ fi
+ printf '\n'
+}
+```
+
+Note the `&` escapes: writing `&amp;` directly inside a `jq` replacement
+string is fine, but `&` in some `gsub` implementations is a back-reference, so
+the escape keeps it literal regardless.
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `10 passed, 0 failed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): build notification bodies
+
+Three rows of author and subject, then +N more, which bounds the
+height so a mailing list burst is not a wall. Markup characters are
+escaped because dunst renders body markup and a subject is untrusted
+text off the internet."
+```
+
+---
+
+## Task 4: Read and write the state file
+
+The UUID rule and the silent-seed rule both live here. Both exist to prevent a
+notification storm.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+- Modify: `mail-overview/test-mail-notify.sh`
+
+- [ ] **Step 1: Write the failing test**
+
+Add to `test-mail-notify.sh` before the summary:
+
+```bash
+# A scratch state file, removed at exit. Never the real one: that is the
+# user's live notification position, and a test must not move it.
+tmpstate="$(mktemp)"
+trap 'rm -f "$tmpstate"' EXIT
+STATE="$tmpstate"
+
+rm -f "$tmpstate"
+check "a missing state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 500
+check "a revision written is a revision read back" \
+ "500" "$(read_prev_rev "uuid-a")"
+
+check "a different database UUID discards the revision" \
+ "" "$(read_prev_rev "uuid-b")"
+
+printf 'garbage not a state file\n' > "$tmpstate"
+check "an unparseable state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 600
+check "a rewrite replaces rather than appends" \
+ "1" "$(wc -l < "$tmpstate")"
+```
+
+- [ ] **Step 2: Run it to verify it fails**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: FAIL, `read_prev_rev: command not found`.
+
+- [ ] **Step 3: Write the minimal implementation**
+
+Add to `mail-notify.sh` after `build_body`:
+
+```bash
+# The last revision this script notified up to, or empty when there is none
+# to trust. Empty means "seed silently": record where we are now and notify
+# nothing.
+#
+# The stored UUID is checked because notmuch revisions are only comparable
+# within one database. A rebuilt database restarts the counter, so an old
+# revision would be meaningless, and treating it as a floor would either
+# notify nothing forever or notify everything at once.
+read_prev_rev() {
+ local want_uuid="$1" got_uuid rev
+
+ [[ -f "$STATE" ]] || return 0
+ read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
+
+ [[ "$got_uuid" == "$want_uuid" ]] || return 0
+ [[ "$rev" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s' "$rev"
+}
+
+# Written by atomic replace, the same idiom mail-watcher uses for its
+# heartbeat: a reader must never see a half-written file, and mv within a
+# directory is atomic where a redirect into the final path is not.
+#
+# Failure to write is deliberately not fatal. The notifications have already
+# been sent; taking the watcher down over a failure to record that would turn
+# a bookkeeping problem into a no-mail-notifications problem.
+write_state() {
+ local uuid="$1" rev="$2" tmp
+
+ mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
+ tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
+ printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
+ mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
+ return 0
+}
+```
+
+- [ ] **Step 4: Run it to verify it passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `15 passed, 0 failed`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh mail-overview/test-mail-notify.sh
+git commit -m "feat(mail-overview): track the notified revision
+
+Stores the database UUID beside the revision, because notmuch
+revisions only compare within one database and a rebuild restarts
+the counter. Missing, corrupt or mismatched state reports nothing,
+which the caller treats as seed-silently: with no floor, lastmod:0..
+matches every unread message and startup becomes a wall of popups."
+```
+
+---
+
+## Task 5: The tick, the notification, and the watch loop
+
+Wires the pure functions to notmuch, dunstify and inotify. These are the
+edges, each named in exactly one place.
+
+**Files:**
+- Modify: `mail-overview/mail-notify.sh`
+
+- [ ] **Step 1: Write the implementation**
+
+Replace the `main() { echo "not implemented"; }` stub in `mail-notify.sh`
+with everything below:
+
+```bash
+# Sends one notification for one account.
+#
+# dunstify rather than notify-send because actions need it. A 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.
+#
+# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
+# on most dunst configurations critical notifications never expire, which
+# would leave mail popups on screen until clicked.
+#
+# The click cannot open the account it belongs to. qtmaildir accepts no
+# command line arguments and startup_account is a static config setting, not
+# a flag, which is the same limitation the drawer's thread rows already have.
+notify_account() {
+ local label="$1" key="$2" count="$3" body="$4"
+
+ if ! command -v dunstify >/dev/null 2>&1; then
+ # No actions available, but a notification without a click is still
+ # worth having.
+ notify-send -a mail-overview -u normal -t 10000 \
+ "$label · $count new" "$body"
+ return 0
+ fi
+
+ # Backgrounded because -b blocks until the notification is dismissed or
+ # clicked. Without this the loop would stall for the full timeout on
+ # every account, and a five-account batch would take most of a minute.
+ (
+ if [[ "$(dunstify -a mail-overview -u normal -t 10000 -b \
+ -h "string:x-dunst-stack-tag:mail-$key" \
+ -A "default,open" \
+ "$label · $count new" "$body")" == "default" ]]; then
+ "$HOME/bin/qtmaildir" &
+ fi
+ ) >/dev/null 2>&1 &
+}
+
+# One pass: what has arrived since the revision we last saw.
+tick() {
+ local lastmod uuid cur prev
+
+ # Three tab-separated fields: count, database UUID, revision. Verified on
+ # notmuch 0.39.
+ lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
+ uuid="$(printf '%s' "$lastmod" | cut -f2)"
+ cur="$(printf '%s' "$lastmod" | cut -f3)"
+
+ # 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,
+ # while a query Xapian merely misparses returns a plausible wrong number
+ # and exits 0. The defence against the second is that SCOPE is a fixed
+ # string and is never built from anything.
+ [[ "$cur" =~ ^[0-9]+$ ]] || return 0
+ [[ -n "$uuid" ]] || return 0
+
+ prev="$(read_prev_rev "$uuid")"
+
+ # No trustworthy floor: record where we are and say nothing. This is the
+ # first run, a rebuilt database, or a corrupt state file.
+ if [[ -z "$prev" ]]; then
+ write_state "$uuid" "$cur"
+ return 0
+ fi
+
+ # Nothing committed since last time, or the counter went backwards.
+ if [[ "$cur" -le "$prev" ]]; then
+ return 0
+ fi
+
+ local key label query count rows
+ while IFS=$'\t' read -r key label; do
+ [[ -n "$key" ]] || continue
+
+ query="$SCOPE and tag:account-$key and lastmod:$prev..$cur"
+
+ count="$(notmuch count "$query" 2>/dev/null)"
+ # Same validation, same reason: an empty string must not become a
+ # zero, and a failure here skips this account rather than the batch.
+ [[ "$count" =~ ^[0-9]+$ ]] || continue
+ [[ "$count" -gt 0 ]] || continue
+
+ rows="$(notmuch search --format=json --limit="$ROWS" \
+ --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
+
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ done < <(parse_accounts < "$CONFIG")
+
+ # Written only after every account is done, so a failure part way through
+ # leaves prev unchanged and the next tick retries rather than dropping a
+ # batch silently.
+ write_state "$uuid" "$cur"
+}
+
+main() {
+ local db
+ db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+ if [[ ! -d "$db" ]]; then
+ echo "mail-notify: no notmuch database at $db" >&2
+ exit 1
+ fi
+
+ if [[ ! -r "$CONFIG" ]]; then
+ echo "mail-notify: cannot read $CONFIG" >&2
+ exit 1
+ fi
+
+ if [[ "${1:-}" == "--once" ]]; then
+ tick
+ return 0
+ fi
+
+ # Seed before watching, so a first run never notifies the backlog.
+ tick
+
+ # The watch is on the xapian DIRECTORY, not a file inside it: a commit
+ # replaces files, and a watch held on a filename dies with the file.
+ while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this, a single sync fires
+ # three or four ticks.
+ sleep 0.3
+ tick
+ done
+
+ # Falling out means inotifywait itself failed. Say so rather than exiting
+ # silently, which is indistinguishable from no mail arriving.
+ echo "mail-notify: inotify watch stopped" >&2
+ exit 1
+}
+```
+
+- [ ] **Step 2: Verify the test still passes**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS, `15 passed, 0 failed`. The pure functions are unchanged, and
+this is the check that Task 5 did not break them.
+
+- [ ] **Step 3: Verify a seed run is silent**
+
+```bash
+export MAIL_NOTIFY_STATE=/tmp/mail-notify-test.lastmod
+rm -f "$MAIL_NOTIFY_STATE"
+./mail-overview/mail-notify.sh --once
+cat "$MAIL_NOTIFY_STATE"
+```
+
+Expected: **no notifications appear**, and the state file holds one line of
+`<uuid> <revision>`. This is the anti-storm rule working: 101 unread messages
+and not one popup.
+
+- [ ] **Step 4: Verify a backdated run notifies**
+
+This is the verification the spec calls for, and it does not need mail to
+arrive:
+
+```bash
+uuid="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f2)"
+cur="$(notmuch count --lastmod 'tag:unread and tag:inbox' | cut -f3)"
+printf '%s %s\n' "$uuid" "$((cur - 2000))" > "$MAIL_NOTIFY_STATE"
+./mail-overview/mail-notify.sh --once
+```
+
+Expected: one notification per account that had mail in that window, each
+summarised `<label> · N new` with up to three author/subject rows and a
+`+N more` line when the batch is bigger. Confirm the counts look plausible
+against the drawer, that stacking replaced rather than piled up, and that
+clicking one opens qtmaildir.
+
+- [ ] **Step 5: Clean up the scratch state**
+
+```bash
+rm -f /tmp/mail-notify-test.lastmod
+unset MAIL_NOTIFY_STATE
+```
+
+The real state file was never touched: every step above overrode it.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/mail-notify.sh
+git commit -m "feat(mail-overview): notify per account when mail arrives
+
+Asks notmuch what changed since the revision last seen, then sends
+one notification per account with mail in that window. Counts are
+validated as integers because a rejected notmuch query prints nothing
+and exits 1, and an empty string must not read as zero.
+
+dunstify is backgrounded because -b blocks until the notification is
+dismissed, which would otherwise stall the loop for the full timeout
+on every account. The watch is on the xapian directory rather than a
+file inside it, because a commit replaces files and a watch held on a
+filename dies with it."
+```
+
+---
+
+## Task 6: Documentation and autostart
+
+**Files:**
+- Modify: `mail-overview/README.md`
+- Modify: `~/.config/hypr/sections/autostart.lua` (outside the repo, not committed)
+
+- [ ] **Step 1: Add the README section**
+
+Insert into `mail-overview/README.md`, between the `## The waybar module`
+section and `## Geometry, and the one property that differs from the other
+panels`:
+
+```markdown
+## Notifications on arrival
+
+`mail-notify.sh` sends one notification per account when mail lands, with the
+newest three senders and subjects and a `+N more` line when the batch is
+bigger. Started from `autostart.lua`, it runs for the whole session.
+
+ /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
+
+It watches the same xapian directory the waybar module does, for the same
+reason and with the same 0.3s debounce. It is a **separate process rather
+than part of `waybar-mail.sh`**, which already has that edge: waybar owns
+that script's process, so a bar restart would stop notifications with nothing
+reporting it.
+
+**A commit is not the same as new mail.** Reading a message in qtmaildir drops
+its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
+revision counter instead:
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+which prints count, database UUID and revision, tab separated. Each account is
+then asked what it gained in `lastmod:<prev>..<cur>`. A count delta was
+rejected because it cannot name senders, and a `date:` watermark because
+`date:` is the message's own Date header: backdated mail would never notify
+and future-dated mail would notify forever.
+
+Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
+replace, holding the **UUID as well as the revision**. Revisions only compare
+within one database, so a rebuild restarts the counter and a stored revision
+from the old one means nothing.
+
+**Missing, corrupt or mismatched state seeds silently**, recording the current
+revision and notifying nothing. Without that floor, `lastmod:0..` matches every
+unread message ever: 101 of them here, which is a wall of popups at every
+login. The same applies to a restart, so mail that arrived while it was down is
+never notified. The waybar count is still right and the drawer still shows it,
+so nothing is lost but a stale popup.
+
+Clicking a notification opens qtmaildir, but not the account it belongs to:
+`qtmaildir` takes no arguments and `startup_account` is a static setting rather
+than a flag, the same limitation the thread rows have.
+
+`test-mail-notify.sh` is the check. It sources the script as a library and
+asserts on the functions that only move text around, so it needs no mail and no
+notmuch:
+
+ ./test-mail-notify.sh
+```
+
+- [ ] **Step 2: Verify the README claims are true**
+
+```bash
+./mail-overview/test-mail-notify.sh
+```
+
+Expected: PASS. The README now promises this command works, so it must.
+
+- [ ] **Step 3: Commit the README**
+
+```bash
+git add mail-overview/README.md
+git commit -m "docs(mail-overview): document the arrival notifier
+
+Why a revision counter rather than a count delta or a date
+watermark, why the state file carries a UUID, and why an unseeded
+run stays quiet."
+```
+
+- [ ] **Step 4: Add the autostart line**
+
+Edit `~/.config/hypr/sections/autostart.lua`, after the mail-watcher line
+(currently line 28, `hl.exec_cmd("/home/you/bin/mail-watcher.sh --ensure")`),
+substituting the real home directory for `/home/you`:
+
+```lua
+ -- one notification per account when mail lands
+ hl.exec_cmd("/home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh")
+```
+
+The absolute path is required: Hyprland's Lua strings do not expand `~`, the
+same reason the `qs -p` lines above are spelled out. This file is outside the
+repo and is not committed; no home path enters git.
+
+- [ ] **Step 5: Reload and confirm it is running**
+
+```bash
+hyprctl reload
+pgrep -cf mail-notify.sh
+```
+
+Expected: `1`. Note this is one of the few safe uses of `pgrep -f` here, since
+the script name is distinctive; `pkill -f` remains forbidden in this repo
+because it matches the agent's own shell.
+
+---
+
+## Self-Review
+
+**Spec coverage.** Shape and placement: Task 1 and Task 5's `main`. Arrival
+detection via `lastmod`: Task 5's `tick`. State file with UUID and silent
+seeding: Task 4. Account parsing with both traps: Task 2. Notification content,
+stack tag, urgency, timeout, click action, `notify-send` fallback: Tasks 3 and
+5. Failure handling and integer validation: Task 5, tested in Task 3's
+malformed-JSON case. Verification by backdated revision: Task 5 Step 4.
+Documentation: Task 6. No spec section is unimplemented.
+
+**Placeholder scan.** No TBDs, no "add error handling", no "similar to Task N".
+Every code step carries its code.
+
+**Type consistency.** `parse_accounts` emits `key<TAB>label` and is consumed
+that way in Task 5. `build_body "$json" "$total"` is defined in Task 3 and
+called with that signature in Task 5. `read_prev_rev "$uuid"` and
+`write_state "$uuid" "$rev"` match between Tasks 4 and 5. `STATE`, `CONFIG`,
+`SCOPE` and `ROWS` are declared in Task 1 and used unchanged after.
+
+**One correction made during review:** Task 4's test needed `STATE` to be
+overridable, so Task 1 declares it as `${MAIL_NOTIFY_STATE:-...}` rather than
+a bare path. `CONFIG` gained the same treatment for symmetry. Without it the
+check would have moved the user's real notification position.
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.