From 4336e1999a665eceb3185e921c17ecb905790d82 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Sun, 13 Sep 2026 17:08:33 +0200 Subject: 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 Claude-Session: https://claude.ai/code/session_017ZwWrCEbdKzmisfg9bb1nS --- .../plans/2026-09-13-mail-arrival-notifications.md | 924 +++++++++++++++++++++ 1 file changed, 924 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-13-mail-arrival-notifications.md 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. +# +# 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. +# +# 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 "keylabel" 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 `keylabel`, 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":"