aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-09-12 09:38:16 +0200
committerDanilo M. <danix@danix.xyz>2026-09-12 09:38:16 +0200
commit31b5963b92c84b6ca94d2e438769654226dd64af (patch)
treeb32b930e005417499345ba37122e2391d4ce6887
parentf909b171956f280e80842191fa1c46bc4b5a282d (diff)
downloadquickshell-31b5963b92c84b6ca94d2e438769654226dd64af.tar.gz
quickshell-31b5963b92c84b6ca94d2e438769654226dd64af.zip
docs: design for mail-overview, a notmuch mail drawer
Replaces three waybar modules that each poll the Gmail API through a python script with its own credentials file, cover three of five accounts, and open Thunderbird. Everything they fetch over the network is already in the local notmuch index, tagged per account. Two decisions carry most of the design. Counts are scoped to "tag:unread and tag:inbox" rather than plain "tag:unread", which for one account is the difference between 32 and 41, because the inbox number is the one that means new mail worth looking at. And the waybar module runs in continuous mode with an inotifywait loop, so waybar owns the watcher process and there is no daemon to supervise on a machine with no systemd; the watch is on the xapian directory rather than on named files because a commit replaces them, which breaks a watch held on a file. The account list is not in the component. qtmaildir.conf already has one [account.<key>] section per account, where <key> is the notmuch tag suffix, carrying a display label and a colour, so the panel parses that and a new account appears without touching QML. Thread level actions are out of scope because they are blocked rather than deferred: qtmaildir takes no arguments, so nothing can tell it which thread to open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WWL8JYHu7yhAdtx5pU9PMU
-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.