aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-12-mail-overview-design.md
diff options
context:
space:
mode:
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.md259
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.