aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-13 17:08:33 +0200
committerDanilo M. <danix@danix.xyz>2026-09-13 17:08:33 +0200
commit4336e1999a665eceb3185e921c17ecb905790d82 (patch)
tree06373fece392fc3be4c97952cffa7b92de58e290 /docs/superpowers/plans
parent1d4bb87c03b2ccec6555ace002c5a519abb86f74 (diff)
downloadquickshell-4336e1999a665eceb3185e921c17ecb905790d82.tar.gz
quickshell-4336e1999a665eceb3185e921c17ecb905790d82.zip
docs: implementation plan for mail arrival notifications
Six tasks, TDD against one bash check rather than a framework: the script is sourced as a library so the pure functions, the ones that only move text around, are asserted without notmuch, dunstify or inotify in the picture. The two parsing traps that already cost this component a debugging session each get a fixture apiece: a key containing dots, and a folder value containing a bracket. Verification does not wait for mail. Backdating the stored revision by 2000 replays a real window on demand, and the seed run before it proves the anti-storm rule with 101 unread messages on disk. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017ZwWrCEbdKzmisfg9bb1nS
Diffstat (limited to 'docs/superpowers/plans')
-rw-r--r--docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md924
1 files changed, 924 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.