diff options
Diffstat (limited to 'docs/superpowers/specs/2026-09-12-mail-overview-design.md')
| -rw-r--r-- | docs/superpowers/specs/2026-09-12-mail-overview-design.md | 259 |
1 files changed, 259 insertions, 0 deletions
diff --git a/docs/superpowers/specs/2026-09-12-mail-overview-design.md b/docs/superpowers/specs/2026-09-12-mail-overview-design.md new file mode 100644 index 0000000..571c26f --- /dev/null +++ b/docs/superpowers/specs/2026-09-12-mail-overview-design.md @@ -0,0 +1,259 @@ +# mail-overview design + +Date: 2026-09-12 + +A fourth quickshell component: a drawer giving an overview of the notmuch +indexed Maildir, with per account unread counts and the three newest unread +threads per account, opened from a single waybar icon that carries the total. + +## Why + +The current waybar setup runs three `custom/mail` modules, each polling the +Gmail API through a python script with its own credentials file, each showing +one account's count, each opening Thunderbird on click. That misses two of the +five accounts, needs network access and stored credentials to report a number +that is already in the local notmuch index, and opens a client that is not the +one in use. + +Everything needed is local: notmuch tags every message with +`account-<key>`, and `qtmaildir.conf` already lists the accounts with display +labels and colours. + +## Scope + +In: + +- one waybar module, icon plus the total unread across all accounts, updated + on notmuch commit rather than on a timer +- a drawer dropping from under the waybar, per account rows with unread counts + and the three newest unread threads +- buttons to launch qtmaildir and to run the sync script + +Out, and why: + +- a notification daemon. Unrelated and larger; new mail already shows as the + icon count changing. +- thread level actions (open this thread, mark read, archive). Blocked, not + deferred: qtmaildir takes no command line arguments, so nothing can tell it + which thread to open, and tagging from the panel would write the database + behind a possibly running client. The panel is read only. +- per account open. Same blocker. `startup_account` in the config is a static + setting, not a flag. +- deduplicating `Theme.qml`. A pre existing loose end across the other three + components; fixing it touches all of them and is its own change. This + component carries the same copy. + +## Architecture + +Two independent readers of one source of truth, the notmuch database. No +shared state, no daemon, no count cache. + + Maildir --mbsync--> notmuch database (xapian) + | + +---------------------+--------------------+ + | | + waybar script (continuous mode) qs mail-overview panel + inotifywait loop on the xapian dir notmuch count + search on open + prints one JSON line per commit parses qtmaildir.conf + click -> qs ipc call mail toggle + +The database is written by the sync script on a cron schedule and by qtmaildir +when mail is read, so watching it covers both directions: the count rises on +new mail and falls as mail is read, with no polling in either case. + +### Files + +| File | Role | +| --- | --- | +| `mail-overview/shell.qml` | keepalive window, IpcHandler, the drawer | +| `mail-overview/Accounts.qml` | parses the config, runs notmuch, exposes the model | +| `mail-overview/Theme.qml` | the existing copy, unchanged | +| `mail-overview/waybar-mail.sh` | continuous mode watcher script | +| `mail-overview/README.md` | component notes | + +## Accounts come from qtmaildir.conf + +The account list is never hardcoded. `qtmaildir.conf` has one +`[account.<key>]` section per account, and `<key>` is exactly the suffix of +the notmuch tag `account-<key>`. Each section carries `label` (short display +name) and `color`. Sections are taken in file order, which is the display +order. Adding an account to qtmaildir makes it appear in the panel. + +Parsing: read the file with `FileView` and `watchChanges: true`, so editing +the config updates the panel with no restart. Parse with an `exec` loop, not +`String.matchAll`: QML's JS engine has no `matchAll` and throws, which inside +a `try` looks like a parser quietly returning nothing. + +Missing `label` falls back to the key. Missing `color` falls back to the Theme +foreground. + +## Unread means inbox unread + +Every count in both the icon and the panel is +`tag:unread and tag:inbox`, per account with +`and tag:account-<key>` appended. + +Plain `tag:unread` also counts archived but unread mail and mailing list +traffic that was never in the inbox, which for one account is 41 against 32. +The inbox scoped number is the one that means "new mail worth looking at". + +## The waybar module + +Continuous mode: the script does not exit, it prints one JSON line per +update, and waybar redraws on each line. The module therefore has no +`interval`, and there is no separate watcher process to supervise. Waybar owns +the process lifetime. + + #!/bin/bash + db="$(notmuch config get database.path)/xapian" + emit() { + n=$(notmuch count 'tag:unread and tag:inbox') + ... + } + emit + while inotifywait -qq -e close_write,moved_to "$db"; do + sleep 0.3 + emit + done + +Three things in that loop are deliberate: + +- the watch is on the **directory**, not on named files. Xapian replaces files + on commit (unlink and rename), which breaks a watch held on a file. +- `close_write,moved_to` are the events a commit produces. `iamglass` is + rewritten every time. +- the short sleep coalesces the several file writes of one commit into one + emitted line. + +Output is JSON so the module can carry a `class`: `unread` when the count is +above zero, `empty` at zero so the stylesheet can dim it. A failing +`notmuch count` emits an error class rather than exiting the loop, so a +transient database lock does not silently kill the module. + +The count is a single accumulated total across all accounts. The script does +not parse the config: a total is a total, and that keeps it to one notmuch +call. + +Left click toggles the drawer over IPC. Right click runs the sync script. + +## The drawer + +A `PanelWindow` anchored top and right on the primary monitor, with +`exclusionMode` set to respect other surfaces' exclusive zones while claiming +none of its own. Waybar sets an exclusive zone, so the compositor places the +drawer below it without this component knowing waybar's height. Anchoring +right puts it under the icon, which sits in `modules-right`, with no +coordinate arithmetic to go stale when the module list changes. + +Width is fixed at roughly 460px, height follows the content. + +A distinct layershell namespace, `qs-mail`, so a Hyprland layer rule can blur +it. Without the rule it renders flat translucent. + +Escape closes it. The focus is set on the inner content item, not on the +window: key events reach a focused item, and `Keys.onEscapePressed` on a +`PanelWindow` never fires. Clicking outside also closes it. + +The component holds itself open with the usual 1x1 transparent keepalive +`PanelWindow` with an empty mask. Without it a config whose only window is +hidden exits silently, and the symptom is a keybind that appears to do +nothing. + +### Layout + + +------------------------------------------+ + | Mail 101 unread | + +------------------------------------------+ + | * Account A 36 | + | Some Sender Today 06:18 | + | [a-list] a subject line that is... | + | ...two more... | + +------------------------------------------+ + | * Account B 19 | + +------------------------------------------+ + | * Account C 32 | + | * Account D 4 | + | * Account E 1 | + +------------------------------------------+ + | Open qtmaildir Sync now | + +------------------------------------------+ + +The dot carries the account's own `color` from the config. Labels and counts +use Theme colours: the per account colours are identity from configuration, +not a palette, and no component defines a palette. + +Each account shows its three newest unread threads as author, relative date +and subject, author and subject elided. These are read only for the reason in +Scope. An account with no unread mail keeps its row with a dimmed count and no +thread rows. + +"Open qtmaildir" launches the client. "Sync now" runs the sync script, which +is already lock protected against a concurrent cron run, and is disabled while +running; the watcher picks up whatever the sync commits. + +### Data + +On open, per account: + + notmuch search --format=json --limit=3 --sort=newest-first \ + 'tag:unread and tag:inbox and tag:account-<key>' + notmuch count 'tag:unread and tag:inbox and tag:account-<key>' + +Two calls because a search limited to three rows cannot report the total. Five +accounts is ten short calls against a local index. + +A `Process` that is already running ignores an assignment of `running = true`, +so any `Process` reused for a sequence of commands is set to `running = false` +immediately before each start. + +A refresh timer runs while the drawer is visible and is stopped when it is +hidden. The drawer is open for seconds at a time, so it does not need its own +inotify watch on top of the timer. + +## Error handling + +- notmuch missing, or the database locked: the count shows a dash, never a + zero. A zero that is actually a failure reads as "no new mail", which is the + same class of mistake as reporting a libvirt host side figure as guest + memory. +- config unreadable, or no `[account.*]` sections: the panel says so in one + line. The waybar total still works, since the script does not read the + config. +- `notmuch search` exits non zero: keep the previous counts rather than + blanking the panel. +- the waybar script's `notmuch count` fails: emit an error class and keep + looping. + +## Verification + +The watcher is the one piece with non trivial logic, and it is runnable on its +own: start `waybar-mail.sh` in a terminal, change a tag with +`notmuch tag +unread -- id:<some id>` in another, and confirm a new line +prints within about a second. That check fails if the event set, the directory +watch or the debounce is wrong. + +For the panel, start `qs -p mail-overview` so the harness owns the process and +confirm from the log. A detached `qs` does not survive an agent's tool call and +a later `pgrep` reports it dead regardless of whether the config is sound. The +process is named `qs`, so `pkill -x qs` and `pgrep -cx qs`; never `-f`, which +matches the caller's own command line. + +Visual judgement is the user's. No screenshots of a transient drawer. + +## Live configuration, not in this repo + +Required for the component to work, but these files are the live Hyprland and +waybar configuration, which carries absolute home paths and is not committed +here. + +1. The waybar mail module file: drop the three Gmail API entries, add one + `custom/mail` with the script, the JSON return type, and the click actions. + In the bar config, replace the three module names in `modules-right` with + the one. Optionally a dim rule for the empty class in the stylesheet. +2. A Hyprland layer rule for the `qs-mail` namespace in the decorations + section, matching what the other components use, then `hyprctl reload`. +3. An autostart line for the component alongside the existing three, so the + IPC target exists when waybar clicks it, then `hyprctl reload`. + +Dead once the modules are replaced: the polybar gmail module directory with +its python script and three credentials files. Nothing else references it. |
