diff options
Diffstat (limited to 'docs/superpowers')
| -rw-r--r-- | docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md | 924 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md | 61 |
2 files changed, 960 insertions, 25 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 & B — <script>" \ + "$(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("&"; "&") | gsub("<"; "<") | gsub(">"; ">") + ' 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 `&` 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 index 0b4db9b..1423fe9 100644 --- a/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md +++ b/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md @@ -59,12 +59,14 @@ 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 count "tag:unread and tag:inbox and tag:account-<key> and lastmod:<prev+1>..<cur>" notmuch search --format=json --limit=3 --sort=newest-first "<same query>" Two calls, the same count-plus-preview pair `Accounts.qml` already makes, and only for accounts whose query is non-empty. The count gives the true N for -"+N more"; the search gives the rows. +"+N more"; the search gives the rows. The lower bound is exclusive (`prev+1`) +so the revision just written, which is inclusive at the top end, is not +re-matched on the next tick. `search --format=json` returns `authors` and `subject` directly, which is all the body needs. @@ -95,9 +97,12 @@ 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. +The new revision is written **after** every account has been processed. A +single account whose count does not validate is skipped for that tick, and the +revision still advances: holding it back would make every later tick re-notify +the successful accounts' whole range. Only a failure to write the state file +itself is non-fatal and leaves the old revision, so the next tick re-notifies +rather than dropping mail. ## Accounts @@ -120,39 +125,45 @@ The `label` is what the notification summary shows. One per account with new mail: - dunstify -a mail-overview -u normal -t 10000 \ + dunstify -a "New Mail" -u normal -t 10000 -b \ + -i "$MAIL_ICON" \ -h string:x-dunst-stack-tag:mail-<key> \ -A default,open \ - "<label> · N new" "<body>" + "<label> (<count>)" "<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. +The heading is split to match the running dunst `format`, which renders `%a` +(the app name) bold on the top line and `%s` (the summary) italic below it: +the app name is `New Mail`, the summary is `<label> (<count>)`. + +Body: up to 3 bullet rows of author and subject, each on its own line with a +blank line between, then `+N more` when N > 3. Three matches the drawer's own +`--limit=3`, and bounds the height so a 20-message mailing list burst does not +become a wall. + +`MAIL_ICON` is an absolute path, not an icon name. The running dunst resolves +a themed icon name only through the `icon_path` in `dunstrc`, which holds no +mail icon, so a name renders nothing; an absolute path under the active theme +resolves, as the machine's other notifiers already do. It points at +`${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg`. + +Because the notification carries `-A`, dunst prepends an `(A)` action +indicator when `show_indicators` is on, so `dunstrc` sets +`show_indicators = no`. Normal urgency and an explicit 10s timeout. Deliberately not `-u critical`: -on most dunst configurations critical notifications never expire, which would -leave mail popups stuck on screen. +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 +until the notification is dismissed or actioned and print the action key, so each notification is launched in a backgrounded subshell that waits and runs `~/bin/qtmaildir` on `default`. Without backgrounding, the loop would stall -for the full timeout on every account. - -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. +for the full timeout on every account. With the running dunst's +`mouse_middle_click = do_action`, it is a middle click that invokes it. ## Failure handling |
