# 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-`, 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/Button.qml` | the existing copy from vm-manager, plus an enabled state | | `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.]` section per account, and `` is exactly the suffix of the notmuch tag `account-`. 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-` 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". ### Tags, not paths qtmaildir scopes a single account with a path glob, `path:"/**" and tag:unread`. This design uses the `account-` tag instead, and the difference is not cosmetic. notmuch deduplicates by message id, so one message that arrived at two of the configured addresses is a single message with two file paths. A path glob matches it under both accounts, so the per account counts sum to more than the total: measured here, five accounts summed to 102 against a global total of 101. The `account-*` tag is a property of the message, so it is singular, and the per account counts always sum to the total. The waybar icon shows one global total and the panel header shows the same number, so rows that sum to something else would read as a bug. The cost is that a cross posted message appears under only one account, whichever the post-new hook attributed it to. That is the right trade for an overview whose headline figure is a single number. Note also that the qtmaildir query has no inbox term: the path glob restricts to the account but not to the inbox, so it includes archived unread mail unless qtmaildir adds its own scope. The queries here are always inbox scoped. ## 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 fullscreen `PanelWindow` on the primary monitor holding a dimmed backdrop, with the drawer itself a 460px wide rounded rectangle anchored to the overlay's top right corner and sized to its content. That is the idiom both other panels in this repo use, and it is what gives click-outside-to-close and a focusable item for Escape. It differs from those two in one property: `exclusionMode` is `Normal` rather than `Ignore`. Waybar claims an exclusive zone at the top of this screen (measured at 42px), so respecting it places the whole overlay below the bar without this component carrying the bar's height as a constant to drift. The backdrop therefore starts below waybar, leaving the bar visible and un-dimmed. Anchoring the drawer right puts it under the icon, which sits in `modules-right`, with no coordinate arithmetic to go stale when the module list changes. A distinct layershell namespace, `quickshell-mail`, matching the `quickshell-*` names the other components use, 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-' notmuch count 'tag:unread and tag:inbox and tag:account-' 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 exits 0 even for a malformed query, printing something that is not a count, so every count is validated as a non-negative integer and the exit status is not the test. - notmuch missing, the database locked, or a count that fails validation: 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:` 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.