aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/plans/2026-09-12-mail-overview.md
diff options
context:
space:
mode:
Diffstat (limited to 'docs/superpowers/plans/2026-09-12-mail-overview.md')
-rw-r--r--docs/superpowers/plans/2026-09-12-mail-overview.md1339
1 files changed, 1339 insertions, 0 deletions
diff --git a/docs/superpowers/plans/2026-09-12-mail-overview.md b/docs/superpowers/plans/2026-09-12-mail-overview.md
new file mode 100644
index 0000000..63da50b
--- /dev/null
+++ b/docs/superpowers/plans/2026-09-12-mail-overview.md
@@ -0,0 +1,1339 @@
+# mail-overview 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:** A fourth quickshell component: one waybar icon carrying the total
+unread count across all notmuch accounts, which opens a drawer showing per
+account counts and the three newest unread threads per account.
+
+**Architecture:** Two independent readers of the notmuch database, no shared
+state and no daemon. A waybar `custom` module in continuous mode watches the
+xapian directory with `inotifywait` and prints one JSON line per commit. A
+quickshell component parses `qtmaildir.conf` for the account list and runs
+`notmuch` when its drawer opens. The drawer is a fullscreen layershell overlay
+with content anchored top right, matching `vm-manager` and `appearance`.
+
+**Tech Stack:** quickshell 0.3.1 (QML, `Quickshell.Io` `Process`/`FileView`,
+`Quickshell.Wayland` layershell), notmuch 0.39 CLI, inotify-tools 4.23,
+bash, waybar.
+
+**Spec:** `docs/superpowers/specs/2026-09-12-mail-overview-design.md`
+
+---
+
+## Before starting
+
+Read `AGENTS.md` at the repo root. Four traps in it cost real time and this
+plan depends on all four:
+
+1. A quickshell config with no visible window exits silently. Task 2 adds the
+ 1x1 keepalive window and it is not optional.
+2. A detached `qs` does not survive an agent's tool call. Start it so the
+ harness owns the process and verify from its log, never from a later
+ `pgrep`.
+3. The process is `qs`, not `quickshell`. Use `pkill -x qs` and `pgrep -cx qs`.
+ Never `-f`, which matches the caller's own command line.
+4. QML's JS engine has no `String.matchAll`. Use an `exec` loop.
+
+Read `appearance/Udt.qml` before Task 3. It is the closest existing analogue to
+`Accounts.qml`: a singleton that watches a config file, parses it with an exec
+loop, and drives a sequence of commands through one reused `Process`.
+
+## Facts established by measurement
+
+Do not re-derive these; they are why the code looks the way it does.
+
+- Waybar runs on `DP-1` only, anchored top, 42px tall, `layer: top`, with no
+ `position` key (top is the default) and `margin: 0`.
+- There are **five** accounts, with notmuch tags `account-<key>` where `<key>`
+ matches an `[account.<key>]` section in `qtmaildir.conf`.
+- `notmuch` exits **0 even for a malformed query**: `notmuch count 'tag:unread
+ and (('` printed `40` and exited 0. Exit status alone cannot detect failure,
+ so every count is validated as a non-negative integer before use.
+- An empty result is `0` from `count` and `[]` from `search --format=json`,
+ both with exit 0. That is a legitimate zero, not an error.
+- `qtmaildir` accepts no command line arguments, so nothing can ask it to open
+ a particular thread or account.
+- The xapian directory holds `iamglass`, `flintlock`, and three large `.glass`
+ files. A commit rewrites `iamglass` and replaces files, which is why the
+ watch is on the directory and not on a filename.
+
+## File structure
+
+| File | Responsibility |
+| --- | --- |
+| `mail-overview/waybar-mail.sh` | Continuous mode watcher. Prints JSON for waybar. Standalone, no QML involvement. |
+| `mail-overview/Theme.qml` | Palette. Byte for byte copy of `appearance/Theme.qml`. |
+| `mail-overview/Accounts.qml` | Singleton. Parses `qtmaildir.conf`, runs notmuch, exposes the model and a `refresh()`. All data logic. |
+| `mail-overview/MailPanel.qml` | The window, the keepalive window, the layout. All presentation. |
+| `mail-overview/shell.qml` | `ShellRoot`, instantiates the panel, `IpcHandler` named `mail`. |
+| `mail-overview/README.md` | Component notes. |
+
+Every new `.qml` and `.sh` file starts with the GPLv2 header used by every
+other file in the repo, copied from `appearance/shell.qml` lines 1-10.
+
+---
+
+### Task 1: The waybar watcher script
+
+This is the only piece with a real runnable check, and it works with no QML at
+all, so it goes first.
+
+**Files:**
+- Create: `mail-overview/waybar-mail.sh`
+
+- [ ] **Step 1: Write the check, and watch it fail**
+
+The script does not exist yet. Run this to see the shape of the failure you are
+fixing:
+
+```bash
+cd <repo> # the repository working directory
+./mail-overview/waybar-mail.sh
+```
+
+Expected: `bash: ./mail-overview/waybar-mail.sh: No such file or directory`
+
+- [ ] **Step 2: Write the script**
+
+Create `mail-overview/waybar-mail.sh` with the GPLv2 header then:
+
+```bash
+#!/bin/bash
+#
+# Waybar custom module in continuous mode: prints one JSON line per notmuch
+# commit, forever, and waybar redraws on each line. Waybar owns this process,
+# which is why there is no daemon to supervise and no interval to tune.
+#
+# The count is the total across every account. A per account breakdown is the
+# drawer's job; see mail-overview/README.md.
+
+set -u
+
+QUERY='tag:unread and tag:inbox'
+
+# notmuch knows where its own database is, so this follows a moved database
+# without an edit here.
+db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+emit() {
+ local n
+ n="$(notmuch count "$QUERY" 2>/dev/null)"
+
+ # notmuch exits 0 even for a malformed query, printing something that is
+ # not a count, so the exit status is not the test: the output is. Anything
+ # that is not a plain number is a failure, and a failure must not render
+ # as "no new mail".
+ if [[ ! "$n" =~ ^[0-9]+$ ]]; then
+ printf '{"text":"!","tooltip":"notmuch count failed","class":"error"}\n'
+ return
+ fi
+
+ if [[ "$n" -eq 0 ]]; then
+ printf '{"text":"","class":"empty"}\n'
+ else
+ printf '{"text":"%s","class":"unread"}\n' "$n"
+ fi
+}
+
+if [[ ! -d "$db" ]]; then
+ printf '{"text":"!","tooltip":"no notmuch database","class":"error"}\n'
+ exit 1
+fi
+
+emit
+
+while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this the module redraws three
+ # or four times per sync with intermediate counts.
+ sleep 0.3
+ emit
+done
+
+# Falling out of the loop means inotifywait itself failed. Say so rather than
+# exiting silently, which looks like an empty inbox.
+printf '{"text":"!","tooltip":"mail watcher stopped","class":"error"}\n'
+exit 1
+```
+
+Then make it executable:
+
+```bash
+chmod +x mail-overview/waybar-mail.sh
+```
+
+- [ ] **Step 3: Verify the first line is correct**
+
+```bash
+./mail-overview/waybar-mail.sh | head -1
+```
+
+Expected: a single JSON line whose `text` equals the output of
+`notmuch count 'tag:unread and tag:inbox'`, with `"class":"unread"`. Confirm
+the two numbers match:
+
+```bash
+notmuch count 'tag:unread and tag:inbox'
+```
+
+The script will hang after printing, because it is now waiting on inotify.
+Ctrl-C it.
+
+- [ ] **Step 4: Verify it reacts to a database change**
+
+This is the check that fails if the event set, the directory watch or the
+debounce is wrong. In one terminal:
+
+```bash
+./mail-overview/waybar-mail.sh
+```
+
+In a second terminal, pick any unread message and toggle a tag on it, which
+commits to the database without touching the Maildir:
+
+```bash
+id=$(notmuch search --output=messages --limit=1 'tag:unread and tag:inbox')
+notmuch tag -unread -- "$id" # count should drop by one
+notmuch tag +unread -- "$id" # and come back
+```
+
+Expected: the first terminal prints a new line within about a second of each
+command, with the count one lower, then the original count again. Exactly one
+line per command, not three or four: that is the debounce working.
+
+Ctrl-C the script.
+
+- [ ] **Step 5: Verify the failure path renders as an error, not as zero**
+
+Point the script at a query that notmuch accepts and then mangles, by
+temporarily editing `QUERY` to `tag:unread and ((` and running it:
+
+```bash
+sed -i "s/^QUERY=.*/QUERY='tag:unread and (('/" mail-overview/waybar-mail.sh
+./mail-overview/waybar-mail.sh | head -1
+```
+
+Expected: `{"text":"!","tooltip":"notmuch count failed","class":"error"}`
+
+If it instead prints a number, the integer validation is wrong and a broken
+query would silently read as a real count. Restore the query:
+
+```bash
+sed -i "s/^QUERY=.*/QUERY='tag:unread and tag:inbox'/" mail-overview/waybar-mail.sh
+./mail-overview/waybar-mail.sh | head -1
+```
+
+Expected: the real count again.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/waybar-mail.sh
+git commit -m "feat(mail-overview): waybar watcher in continuous mode
+
+Prints one JSON line per notmuch commit rather than polling on an interval,
+so the count drops the moment mail is read in qtmaildir and rises the moment
+mbsync commits, and waybar owns the watcher process: nothing to supervise on
+a machine with no systemd.
+
+The watch is on the xapian directory, not on a file inside it, because a
+commit replaces files and a watch held on a filename dies with it. The short
+sleep coalesces the several writes of one commit into one redraw.
+
+notmuch exits 0 even for a malformed query, printing something that is not a
+count, so the output is validated as an integer rather than trusting the exit
+status. A failure there renders as an error glyph: a count that silently
+reads zero would look exactly like an empty inbox."
+```
+
+---
+
+### Task 2: The component skeleton that stays running
+
+Before any layout, prove the config loads and keeps running. Getting this
+wrong is the trap in `AGENTS.md`: no visible window means a silent exit, and
+the symptom is a keybind that does nothing.
+
+**Files:**
+- Create: `mail-overview/Theme.qml`
+- Create: `mail-overview/MailPanel.qml`
+- Create: `mail-overview/shell.qml`
+
+- [ ] **Step 1: Copy the theme verbatim**
+
+```bash
+cp appearance/Theme.qml mail-overview/Theme.qml
+```
+
+Do not edit it and do not add colours to it. It is a fallback for before
+`~/.cache/wal/udt-palette.qml` is read; the real palette is generated. A fourth
+copy is a known loose end, deliberately out of scope for this plan.
+
+- [ ] **Step 2: Write the minimal panel with the keepalive window**
+
+Create `mail-overview/MailPanel.qml` with the GPLv2 header then:
+
+```qml
+import Quickshell
+import Quickshell.Wayland
+import QtQuick
+
+Scope {
+ id: root
+
+ // Waybar runs on this screen, so the drawer belongs here too.
+ property string monitor: "DP-1"
+ property bool open: false
+
+ readonly property var screenObj:
+ Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0]
+
+ function show() { root.open = true; }
+ function close() { root.open = false; }
+ function toggle() { root.open ? close() : show(); }
+
+ // Quickshell exits once no window is visible, and this drawer is closed
+ // most of the time. See AGENTS.md.
+ PanelWindow {
+ visible: true
+ implicitWidth: 1
+ implicitHeight: 1
+ color: "transparent"
+ exclusionMode: ExclusionMode.Ignore
+ mask: Region {}
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.None
+ }
+}
+```
+
+- [ ] **Step 3: Write the shell root**
+
+Create `mail-overview/shell.qml` with the GPLv2 header then:
+
+```qml
+import Quickshell
+import Quickshell.Io
+
+ShellRoot {
+ MailPanel { id: panel }
+
+ IpcHandler {
+ target: "mail"
+ function toggle() { panel.toggle(); }
+ function show() { panel.show(); }
+ function close() { panel.close(); }
+ }
+}
+```
+
+- [ ] **Step 4: Verify it loads and stays running**
+
+Start it in the foreground so the harness owns the process. A detached `qs`
+does not survive a tool call and a later `pgrep` will report it dead whether
+or not the config is sound, which is the second trap in `AGENTS.md`.
+
+```bash
+timeout 10 qs -p mail-overview 2>&1 | tee /tmp/mail-qs.log
+```
+
+Expected: `Configuration Loaded` in the output, no QML errors, and the command
+runs for the full 10 seconds before `timeout` ends it. If it returns in under
+a second, the config exited on its own: the keepalive window is wrong.
+
+- [ ] **Step 5: Verify IPC reaches it**
+
+In one terminal:
+
+```bash
+qs -p mail-overview
+```
+
+In a second:
+
+```bash
+qs -p mail-overview ipc call mail toggle && echo "ipc ok"
+```
+
+Expected: `ipc ok`. Nothing visible happens yet, which is correct: `open` is a
+property with no window bound to it so far.
+
+Stop it:
+
+```bash
+pkill -x qs; pgrep -cx qs
+```
+
+Expected: `0`. Note `-x`, not `-f`: `pkill -f` would match this shell's own
+command line and kill the caller.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add mail-overview/Theme.qml mail-overview/MailPanel.qml mail-overview/shell.qml
+git commit -m "feat(mail-overview): component skeleton with the keepalive window
+
+Nothing is drawn yet. This commit exists on its own because the thing most
+likely to be wrong at this stage is invisible: a config whose only window is
+hidden exits straight after logging Configuration Loaded, reporting no error,
+and the symptom is a keybind that appears to do nothing.
+
+Theme.qml is a verbatim copy of the one in appearance. It is a fallback for
+before the generated palette is read, not a palette to grow; deduplicating
+the four copies is a separate change."
+```
+
+---
+
+### Task 3: The account model
+
+All data logic, no presentation. Modelled on `appearance/Udt.qml`: read it
+first.
+
+**Files:**
+- Create: `mail-overview/Accounts.qml`
+
+- [ ] **Step 1: Confirm what the parser has to handle**
+
+```bash
+grep -n '^\[account\.\|^label\|^color' ~/.config/qtmaildir/qtmaildir.conf
+```
+
+Expected: five `[account.<key>]` headers, each followed by a `label` and a
+`color`. Note that at least one key contains a dot of its own, so the key is
+everything between `[account.` and the closing `]`, not everything up to the
+first dot.
+
+- [ ] **Step 2: Write the singleton**
+
+Create `mail-overview/Accounts.qml` with the GPLv2 header then:
+
+```qml
+pragma Singleton
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+
+// Which accounts exist, how much unread mail each has, and what the newest of
+// it is.
+//
+// The account list is not here. qtmaildir.conf already carries one
+// [account.<key>] section per account, where <key> is exactly the suffix of
+// the notmuch tag account-<key>, plus a short label and a colour. Parsing that
+// means adding an account to qtmaildir makes it appear here with no edit.
+Singleton {
+ id: root
+
+ readonly property string config: `${Quickshell.env("HOME")}/.config/qtmaildir/qtmaildir.conf`
+
+ // Counting by the account-* tag rather than by a path glob is deliberate.
+ // notmuch deduplicates by message id, so a message that arrived at two of
+ // the configured addresses is one message with two paths: a path glob
+ // counts it under both accounts and the rows then sum to more than the
+ // total the waybar icon shows. The tag is a property of the message, so
+ // it is singular and the rows always sum to the header.
+ readonly property string scope: "tag:unread and tag:inbox"
+
+ // [{ key, label, color, count, threads: [{ authors, date, subject }] }]
+ // count is -1 until known, and stays -1 on failure: see loadNext below.
+ property var accounts: []
+
+ property string error: ""
+ property bool loading: false
+
+ readonly property int total:
+ accounts.reduce((sum, a) => sum + Math.max(a.count, 0), 0)
+
+ readonly property bool anyUnknown: accounts.some(a => a.count < 0)
+
+ // The config is watched, so adding an account in qtmaildir updates this
+ // list without restarting the shell.
+ FileView {
+ path: root.config
+ watchChanges: true
+ onFileChanged: reload()
+ onLoadFailed: {
+ root.accounts = [];
+ root.error = "cannot read qtmaildir.conf";
+ }
+ onLoaded: {
+ root.accounts = root.parseAccounts(text());
+ root.error = root.accounts.length ? "" : "no accounts in qtmaildir.conf";
+ root.refresh();
+ }
+ }
+
+ // Sections in file order, which is the display order.
+ function parseAccounts(conf) {
+ const out = [];
+ // QML's JS engine has no String.matchAll, so this is an exec loop.
+ // At least one key contains a dot of its own, so the key runs to the
+ // closing bracket rather than to the first dot.
+ const re = /^\[account\.([^\]]+)\]([^\[]*)/gm;
+ let m;
+ while ((m = re.exec(conf)) !== null) {
+ const key = m[1];
+ const body = m[2];
+ const field = name => {
+ const f = body.match(new RegExp(`^\\s*${name}\\s*=\\s*(.+)$`, "m"));
+ return f ? f[1].trim() : "";
+ };
+ out.push({
+ key: key,
+ label: field("label") || key,
+ color: field("color"),
+ count: -1,
+ threads: [],
+ });
+ }
+ return out;
+ }
+
+ function refresh() {
+ if (!accounts.length) return;
+ loading = true;
+ proc.next = 0;
+ proc.loadNext();
+ }
+
+ // One process per account, in turn, fetching the count and the newest
+ // three threads together and splitting on a marker: two calls are needed
+ // because a search limited to three rows cannot report the total, and
+ // doing them as one command keeps the pair consistent.
+ Process {
+ id: proc
+ property int next: 0
+ property int current: 0
+
+ function loadNext() {
+ if (next >= root.accounts.length) {
+ root.loading = false;
+ return;
+ }
+ current = next;
+ next++;
+ const q = `${root.scope} and tag:account-${root.accounts[current].key}`;
+ command = ["sh", "-c",
+ `notmuch count ${JSON.stringify(q)}; ` +
+ `echo '===SPLIT==='; ` +
+ `notmuch search --format=json --limit=3 --sort=newest-first ${JSON.stringify(q)}`];
+ // Assigning true to an already-true `running` does nothing, and
+ // this Process is reused for every account in turn.
+ running = false;
+ running = true;
+ }
+
+ stdout: StdioCollector {
+ onStreamFinished: {
+ root.applyResult(proc.current, text);
+ proc.loadNext();
+ }
+ }
+
+ onExited: code => {
+ // A non-zero exit leaves this account's count at whatever it was,
+ // which for a first load is -1 and renders as a dash. Carrying on
+ // to the next account matters: one failure must not blank the
+ // whole panel.
+ if (code !== 0) proc.loadNext();
+ }
+ }
+
+ function applyResult(index, out) {
+ const parts = out.split("===SPLIT===");
+ if (parts.length < 2) return;
+
+ const next = accounts.slice();
+ const acct = Object.assign({}, next[index]);
+
+ // notmuch exits 0 even for a malformed query and prints something that
+ // is not a count, so the output is the test, not the exit status. A
+ // count that cannot be trusted stays -1 and renders as a dash: a zero
+ // here would read as "no new mail", which is the same class of
+ // mistake as showing a libvirt host-side figure as guest memory.
+ const n = parts[0].trim();
+ acct.count = /^\d+$/.test(n) ? parseInt(n, 10) : -1;
+
+ acct.threads = [];
+ try {
+ const rows = JSON.parse(parts[1]);
+ if (Array.isArray(rows)) {
+ acct.threads = rows.map(r => ({
+ authors: String(r.authors ?? ""),
+ date: String(r.date_relative ?? ""),
+ subject: String(r.subject ?? "(no subject)"),
+ }));
+ }
+ } catch (e) {
+ // Keep the count, drop the thread list: a count with no preview is
+ // still useful, and an empty search is a legitimate "[]".
+ }
+
+ next[index] = acct;
+ accounts = next;
+ }
+
+ // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so
+ // there is nothing to tell it about the account or thread clicked.
+ Process { id: openProc; command: [`${Quickshell.env("HOME")}/bin/qtmaildir`] }
+
+ function openClient() {
+ openProc.running = false;
+ openProc.running = true;
+ }
+
+ property bool syncing: false
+
+ // mailsync.sh is already lock-protected against a concurrent cron run, so
+ // this does not need its own guard beyond not stacking clicks.
+ Process {
+ id: syncProc
+ command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`]
+ onExited: {
+ root.syncing = false;
+ root.refresh();
+ }
+ }
+
+ function sync() {
+ if (syncing) return;
+ syncing = true;
+ syncProc.running = false;
+ syncProc.running = true;
+ }
+}
+```
+
+- [ ] **Step 3: Verify the parse and the counts from the log**
+
+Add a temporary probe to `mail-overview/shell.qml`, inside `ShellRoot`:
+
+```qml
+ Component.onCompleted: Qt.callLater(() => {
+ console.log("ACCOUNTS", JSON.stringify(Accounts.accounts.map(a => a.key + ":" + a.label)));
+ })
+ Connections {
+ target: Accounts
+ function onLoadingChanged() {
+ if (!Accounts.loading)
+ console.log("COUNTS", JSON.stringify(Accounts.accounts.map(a => a.label + "=" + a.count)),
+ "total", Accounts.total);
+ }
+ }
+```
+
+Run it:
+
+```bash
+timeout 25 qs -p mail-overview 2>&1 | grep -E 'ACCOUNTS|COUNTS|error|Error'
+```
+
+Expected: an `ACCOUNTS` line with five `key:label` pairs in config order, then
+a `COUNTS` line where every value is a non-negative number, no `-1`, and
+`total` equals:
+
+```bash
+notmuch count 'tag:unread and tag:inbox'
+```
+
+Those two numbers must match exactly. If `total` is higher, something is
+counting by path rather than by tag.
+
+A `-1` for an account means its `notmuch` call failed; read the surrounding log
+lines before continuing. Do not accept a `0` you have not verified against
+`notmuch count 'tag:unread and tag:inbox and tag:account-<key>'`.
+
+- [ ] **Step 4: Remove the probe**
+
+Delete the `Component.onCompleted` block and the `Connections` block added in
+Step 3 from `mail-overview/shell.qml`. They were scaffolding for the check, not
+part of the component.
+
+Verify it still loads clean:
+
+```bash
+timeout 10 qs -p mail-overview 2>&1 | grep -cE 'Error|error:'
+```
+
+Expected: `0`.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/Accounts.qml mail-overview/shell.qml
+git commit -m "feat(mail-overview): account model from qtmaildir.conf
+
+The account list lives in qtmaildir.conf, not here: its [account.<key>]
+sections already name every account, and <key> is exactly the suffix of the
+notmuch tag account-<key>, with a short label and a colour alongside. Parsing
+that file means adding an account to qtmaildir makes it appear in the drawer
+with no edit to any QML. The file is watched, so that happens without a
+restart.
+
+Counting uses the account tag rather than a path glob. notmuch deduplicates
+by message id, so a message that arrived at two configured addresses is one
+message with two paths: a glob counts it under both accounts and the rows
+then sum higher than the total the waybar icon shows, measured here as 102
+against 101.
+
+notmuch exits 0 even for a malformed query, so the output is validated as an
+integer rather than trusting the exit status, and a count that cannot be
+trusted stays -1 to render as a dash. A zero there would read as an empty
+inbox, which is the same class of mistake as reporting a libvirt host-side
+figure as guest memory."
+```
+
+---
+
+### Task 4: The drawer
+
+All presentation. The window idiom is a fullscreen overlay with the content
+anchored where the drawer belongs, which is what both `vm-manager` and
+`appearance` do.
+
+**Files:**
+- Modify: `mail-overview/MailPanel.qml` (the file from Task 2; add the drawer
+ below the keepalive window)
+
+- [ ] **Step 1: Refresh on open**
+
+In `mail-overview/MailPanel.qml`, replace the `show()` function written in
+Task 2 with:
+
+```qml
+ function show() {
+ // Mail has almost certainly arrived since this was last opened, and
+ // for an autostarted shell that is the whole session.
+ Accounts.refresh();
+ root.open = true;
+ }
+```
+
+- [ ] **Step 2: Add the drawer window**
+
+Append inside the `Scope`, after the keepalive `PanelWindow`:
+
+```qml
+ LazyLoader {
+ active: root.open
+
+ PanelWindow {
+ id: win
+ screen: root.screenObj
+
+ anchors { top: true; left: true; right: true; bottom: true }
+ color: "transparent"
+
+ // Normal, not Ignore: waybar claims an exclusive zone at the top
+ // of this screen, so respecting it puts the drawer below the bar
+ // without this file knowing the bar's height. The backdrop then
+ // starts below waybar too, which is why waybar stays un-dimmed.
+ exclusionMode: ExclusionMode.Normal
+
+ WlrLayershell.layer: WlrLayer.Overlay
+ WlrLayershell.namespace: "quickshell-mail"
+ WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive
+
+ Rectangle {
+ anchors.fill: parent
+ color: "#000000"
+ opacity: 0.5
+ MouseArea { anchors.fill: parent; onClicked: root.close() }
+ }
+
+ // Keys reach a focused item, never the window itself: setting
+ // keyboardFocus above is necessary but not sufficient, and
+ // Keys.onEscapePressed on a PanelWindow never fires.
+ Item {
+ anchors.fill: parent
+ focus: true
+ Keys.onEscapePressed: root.close()
+ }
+
+ // Top right, under the waybar icon, which sits in modules-right.
+ Rectangle {
+ id: drawer
+ anchors { top: parent.top; right: parent.right; topMargin: 8; rightMargin: 8 }
+ width: 460
+ height: Math.min(content.implicitHeight + 32, win.height - 24)
+ radius: 14
+ color: Qt.alpha(Theme.base, 0.72)
+ border.width: 1
+ border.color: Qt.alpha(Theme.text, 0.12)
+
+ // Swallows clicks so they do not reach the backdrop and close
+ // the drawer.
+ MouseArea { anchors.fill: parent }
+
+ Column {
+ id: content
+ anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 }
+ spacing: 10
+
+ // Header
+ Item {
+ width: parent.width
+ implicitHeight: Math.max(title.implicitHeight, totalText.implicitHeight)
+
+ Text {
+ id: title
+ anchors.verticalCenter: parent.verticalCenter
+ text: "Mail"
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize + 2
+ font.bold: true
+ }
+
+ Text {
+ id: totalText
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ // A dash rather than a possibly-wrong number while
+ // any account is still unknown.
+ text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread`
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ Text {
+ visible: Accounts.error !== ""
+ width: parent.width
+ text: Accounts.error
+ color: Theme.red
+ wrapMode: Text.WordWrap
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+
+ Repeater {
+ model: Accounts.accounts
+
+ Column {
+ required property var modelData
+ width: content.width
+ spacing: 4
+
+ Item {
+ width: parent.width
+ implicitHeight: 26
+
+ Rectangle {
+ id: dot
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8; height: 8; radius: 4
+ // The account's own colour from the config.
+ // Per-account identity, not a palette.
+ color: modelData.color || Theme.accent
+ }
+
+ Text {
+ anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: modelData.label
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ }
+
+ Text {
+ anchors { right: parent.right; verticalCenter: parent.verticalCenter }
+ text: modelData.count < 0 ? "—" : String(modelData.count)
+ color: modelData.count > 0 ? Theme.text : Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize
+ font.bold: modelData.count > 0
+ }
+ }
+
+ // The newest three unread threads. Read-only:
+ // qtmaildir takes no arguments, so there is no way
+ // to ask it for a particular thread.
+ Repeater {
+ model: modelData.threads
+
+ Column {
+ required property var modelData
+ width: content.width - 18
+ x: 18
+ spacing: 1
+ bottomPadding: 4
+
+ Item {
+ width: parent.width
+ implicitHeight: who.implicitHeight
+
+ Text {
+ id: who
+ anchors.left: parent.left
+ width: parent.width - when.implicitWidth - 10
+ text: modelData.authors
+ elide: Text.ElideRight
+ color: Theme.subtext
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 3
+ }
+
+ Text {
+ id: when
+ anchors.right: parent.right
+ text: modelData.date
+ color: Theme.overlay
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 3
+ }
+ }
+
+ Text {
+ width: parent.width
+ text: modelData.subject
+ elide: Text.ElideRight
+ color: Theme.text
+ font.family: Theme.fontFamily
+ font.pixelSize: Theme.fontSize - 2
+ }
+ }
+ }
+ }
+ }
+
+ Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) }
+
+ Row {
+ anchors.right: parent.right
+ spacing: 10
+
+ Button {
+ text: Accounts.syncing ? "Syncing..." : "Sync now"
+ enabled: !Accounts.syncing
+ onClicked: Accounts.sync()
+ }
+
+ Button {
+ text: "Open qtmaildir"
+ onClicked: { Accounts.openClient(); root.close(); }
+ }
+ }
+ }
+ }
+ }
+ }
+```
+
+- [ ] **Step 3: Add the button component**
+
+`vm-manager/Button.qml` is a plain styled button with exactly this job. Copy it
+rather than writing a second one:
+
+```bash
+cp vm-manager/Button.qml mail-overview/Button.qml
+```
+
+Then confirm it exposes `text`, `enabled` and `onClicked` as used above:
+
+```bash
+grep -n 'property\|signal\|clicked' mail-overview/Button.qml
+```
+
+If it does not expose an `enabled` property, add one and make the background
+and label dim when false:
+
+```qml
+ property bool enabled: true
+ opacity: enabled ? 1 : 0.45
+```
+
+and guard the click:
+
+```qml
+ onClicked: if (root.enabled) root.clicked()
+```
+
+matching whatever identifiers the copied file actually uses.
+
+- [ ] **Step 4: Verify it opens, shows real numbers, and closes**
+
+```bash
+qs -p mail-overview
+```
+
+In a second terminal:
+
+```bash
+qs -p mail-overview ipc call mail toggle
+```
+
+Check, by looking at the screen:
+
+- the drawer is at the top right, below waybar, not overlapping it
+- five accounts in config order, each with a coloured dot and its label
+- every count is a number, not a dash
+- the accounts with unread mail show up to three author/date/subject lines
+- the header total equals the sum of the rows
+
+Then confirm both closes work: press Escape, reopen with the same IPC call, and
+click the dimmed area away from the drawer.
+
+Verify the counts against notmuch rather than trusting the screen:
+
+```bash
+for k in $(grep -oP '^\[account\.\K[^\]]+' ~/.config/qtmaildir/qtmaildir.conf); do
+ printf '%-32s %s\n' "$k" "$(notmuch count "tag:unread and tag:inbox and tag:account-$k")"
+done
+notmuch count 'tag:unread and tag:inbox'
+```
+
+Stop it:
+
+```bash
+pkill -x qs; pgrep -cx qs
+```
+
+Expected: `0`.
+
+The visual result is the user's to judge. Ask them to look; do not screenshot
+a transient drawer.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add mail-overview/MailPanel.qml mail-overview/Button.qml
+git commit -m "feat(mail-overview): the drawer
+
+A fullscreen layershell overlay with the content anchored top right, which is
+the idiom both other panels in this repo use, and which gives click-outside
+and Escape for free.
+
+exclusionMode is Normal rather than Ignore, unlike the other two: waybar
+claims an exclusive zone at the top of this screen, so respecting it places
+the drawer under the bar without this file carrying the bar's height as a
+constant to drift. It also leaves waybar outside the dimmed backdrop.
+
+Focus is on an inner Item, not the window. Setting keyboardFocus is necessary
+but not sufficient: key events reach a focused item, and Keys.onEscapePressed
+on a PanelWindow never fires.
+
+Thread rows are read-only because qtmaildir accepts no arguments, so there is
+nothing to tell it which thread was clicked. An unknown count renders as a
+dash rather than a zero."
+```
+
+---
+
+### Task 5: The README
+
+**Files:**
+- Create: `mail-overview/README.md`
+
+- [ ] **Step 1: Write it**
+
+Follow the shape of `appearance/README.md`. It must cover, in prose:
+
+- what the component is: one waybar icon with the total, a drawer with per
+ account counts and the newest three unread threads each
+- that the account list comes from `qtmaildir.conf` `[account.<key>]` sections,
+ that `<key>` is the notmuch tag suffix, and that adding an account there is
+ all that is needed
+- why counting uses the `account-*` tag and not a path glob, with the
+ deduplication reason and the 102-against-101 measurement
+- that every count is `tag:unread and tag:inbox`, and what plain `tag:unread`
+ would include instead
+- that `notmuch` exits 0 for a malformed query, so counts are validated as
+ integers and an untrusted count shows a dash
+- that the waybar module runs in continuous mode, why the watch is on the
+ xapian directory rather than a file, and what the debounce is for
+- that `exclusionMode: ExclusionMode.Normal` is what puts the drawer under
+ waybar, and that this differs from the other two components
+- that thread rows are read-only because `qtmaildir` takes no arguments
+- how to run it: `qs -p mail-overview`, and
+ `qs -p mail-overview ipc call mail toggle`
+- the live config needed outside the repo, described with `~` paths only: the
+ waybar module, the Hyprland layer rule for namespace `quickshell-mail`, and
+ the autostart line
+
+No absolute home paths anywhere in the file. A gitleaks hook blocks them.
+
+- [ ] **Step 2: Check for home paths before committing**
+
+```bash
+grep -n '/home/' mail-overview/README.md mail-overview/*.qml mail-overview/*.sh
+```
+
+Expected: no output. If anything matches, replace it with a `~` path.
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add mail-overview/README.md
+git commit -m "docs(mail-overview): component notes
+
+Records the two decisions a later reader would otherwise reverse: counting by
+the account tag rather than a path glob, and validating notmuch output as an
+integer because it exits 0 even for a malformed query."
+```
+
+---
+
+### Task 6: Repo documentation
+
+**Files:**
+- Modify: `README.md`
+- Modify: `AGENTS.md`
+
+- [ ] **Step 1: Add the component to the repo README**
+
+Read the existing list of components in `README.md` and add a `mail-overview`
+entry in the same style as the other three: one line saying it is a notmuch
+mail overview drawer with a waybar icon carrying the total.
+
+- [ ] **Step 2: Add the component to the AGENTS.md inventory**
+
+In `AGENTS.md`, the "What this is" section lists the components:
+
+```
+ volume-osd/ volume for output and input, plus what is playing
+ vm-manager/ libvirt drawer: state, live stats, snapshots
+ appearance/ wallpaper picker and colour scheme switcher
+```
+
+Add a fourth line in the same format:
+
+```
+ mail-overview/ notmuch unread counts per account, waybar icon and drawer
+```
+
+The sentence below that block says "Both are started from" and then "Both
+components here are hidden most of the time" in the next section. With four
+components those are wrong; change "Both" to "They" and "Both components here
+are" to "These components are".
+
+- [ ] **Step 3: Add the generalising notes**
+
+Still in `AGENTS.md`, the "Per-component notes" section collects the traps that
+generalise. Add three bullets in the existing style:
+
+```markdown
+- **`notmuch` exits 0 even for a malformed query.** It prints something that
+ is not a count and returns success, so the exit status cannot detect
+ failure: validate the output as an integer. A failure that renders as `0`
+ reads as an empty inbox.
+- **notmuch deduplicates by message id, so one message can have several
+ paths.** A message that arrived at two configured addresses is counted by
+ both accounts under a `path:` glob, and per-account counts then sum above
+ the total. The `account-*` tag is a property of the message, so it is
+ singular.
+- **Xapian replaces files on commit.** A watch held on a filename inside the
+ database directory dies with the file; watch the directory for
+ `close_write,moved_to` instead.
+```
+
+- [ ] **Step 4: Add the exclusion-zone note to the Blur section**
+
+The "Blur" section in `AGENTS.md` already says a new component needs its own
+`hl.layer_rule` and a distinct namespace. Add, after that:
+
+```markdown
+A panel that should sit below waybar rather than over it wants
+`exclusionMode: ExclusionMode.Normal` on its window, which respects waybar's
+exclusive zone without the component knowing the bar's height.
+`mail-overview` does this; the other two use `ExclusionMode.Ignore` and cover
+the whole screen.
+```
+
+- [ ] **Step 5: Verify no home paths crept in**
+
+```bash
+grep -n '/home/' README.md AGENTS.md
+```
+
+Expected: no output.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add README.md AGENTS.md
+git commit -m "docs: add mail-overview to the inventory and its traps to the notes
+
+Three of them generalise beyond this component: notmuch exits 0 for a
+malformed query, notmuch deduplicates by message id so one message can be
+counted under two accounts by a path glob, and xapian replaces files on
+commit so a watch on a filename dies with it."
+```
+
+---
+
+### Task 7: Live configuration, and retiring the old modules
+
+These files are the live waybar and Hyprland configuration. They are not in
+this repo and are not committed here. Do each one, then verify.
+
+`<repo>` below stands for the absolute path of this repository's working
+directory, and `<home>` for the user's home directory. Write the real absolute
+paths into these live files: waybar's `exec` and Hyprland's Lua strings do not
+expand `~`. They appear as placeholders here only because a gitleaks hook
+blocks committed home paths, and it is right to.
+
+**Files (all outside the repo):**
+- Modify: `~/.config/waybar/modules/custom/mail.jsonc`
+- Modify: `~/.config/waybar/config.jsonc`
+- Modify: `~/.config/hypr/sections/decorations.lua`
+- Modify: `~/.config/hypr/sections/autostart.lua`
+
+- [ ] **Step 1: Back up the two waybar files**
+
+```bash
+cp ~/.config/waybar/modules/custom/mail.jsonc ~/.config/waybar/modules/custom/mail.jsonc.bak-20260912
+cp ~/.config/waybar/config.jsonc ~/.config/waybar/config.jsonc.bak-20260912
+```
+
+- [ ] **Step 2: Replace the module definition**
+
+Replace the entire contents of `~/.config/waybar/modules/custom/mail.jsonc`,
+which currently holds three `custom/mail#*` entries polling the Gmail API,
+with one module. Use the absolute path to the script, since this file is live
+config and not committed:
+
+```jsonc
+{
+ "custom/mail": {
+ "format": "<span font='18px'>󰃯</span> {}",
+ "return-type": "json",
+ "exec": "<repo>/mail-overview/waybar-mail.sh",
+ "on-click": "qs -p <repo>/mail-overview ipc call mail toggle",
+ "on-click-right": "<home>/bin/mailsync.sh",
+ "tooltip": false
+ }
+}
+```
+
+Note there is no `interval`: the script never exits and prints a line per
+database commit.
+
+- [ ] **Step 3: Replace the three module names in the bar**
+
+In `~/.config/waybar/config.jsonc`, the `modules-right` array contains:
+
+```jsonc
+ "custom/mail#danixland",
+ "custom/mail#itdanilo",
+ "custom/mail#65danix85",
+```
+
+Replace those three lines with one:
+
+```jsonc
+ "custom/mail",
+```
+
+- [ ] **Step 4: Reload waybar and verify the icon**
+
+```bash
+pkill -x waybar; sleep 1; (setsid waybar >/dev/null 2>&1 &) ; sleep 3; pgrep -cx waybar
+```
+
+Expected: `1`. Then confirm on screen that there is one mail icon showing the
+total, and that it matches:
+
+```bash
+notmuch count 'tag:unread and tag:inbox'
+```
+
+Confirm the watcher is attached to waybar rather than leaked:
+
+```bash
+pgrep -af 'waybar-mail.sh' | wc -l
+```
+
+Expected: `1`.
+
+- [ ] **Step 5: Add the blur rule**
+
+In `~/.config/hypr/sections/decorations.lua`, find the existing `hl.layer_rule`
+entries for the other components and add one for this namespace, matching their
+style:
+
+```lua
+hl.layer_rule("blur", "quickshell-mail")
+```
+
+Copy the exact form and any companion rules (`ignorealpha`, `ignorezero`) the
+others use; without the rule the drawer still works, rendering flat
+translucent.
+
+- [ ] **Step 6: Add the autostart line**
+
+In `~/.config/hypr/sections/autostart.lua`, find the existing `qs -p` lines and
+add one in the same style:
+
+```lua
+"qs -p <repo>/mail-overview"
+```
+
+This matters because the waybar click calls IPC, and IPC needs the shell
+already running.
+
+- [ ] **Step 7: Reload Hyprland and verify end to end**
+
+```bash
+hyprctl reload
+sleep 2
+pgrep -cx qs
+```
+
+Expected: one more `qs` than before the reload, four if all four components
+autostart.
+
+Then confirm the layer exists and is placed below waybar:
+
+```bash
+hyprctl layers | grep -E 'waybar|quickshell-mail'
+```
+
+Click the waybar mail icon and confirm the drawer opens under the bar. Check
+the blur rule took by confirming the drawer is frosted rather than flat.
+
+- [ ] **Step 8: Confirm the Gmail polling is gone**
+
+```bash
+pgrep -af 'launch.py' | wc -l
+```
+
+Expected: `0`. Nothing should be polling the Gmail API any more.
+
+The now-dead files are `~/.config/polybar/modules/gmail/`, holding the python
+script and three `credentials_*.json`. Nothing else references them once the
+modules are replaced. Ask the user before deleting or archiving: credentials
+are theirs to dispose of, and last session's equivalent was archived rather
+than removed.
+
+- [ ] **Step 9: Nothing to commit**
+
+These files are outside the repo by design. Confirm the repo is clean and that
+no live config leaked into it:
+
+```bash
+git status --short # run from the repository root
+```
+
+Expected: no output.
+
+---
+
+## Final verification
+
+- [ ] Waybar shows one mail icon whose count equals
+ `notmuch count 'tag:unread and tag:inbox'`
+- [ ] Reading mail in qtmaildir drops that count within about a second,
+ without a sync
+- [ ] Clicking the icon opens the drawer below waybar
+- [ ] All five accounts appear, in `qtmaildir.conf` order, with their labels
+ and colours, and the rows sum to the header
+- [ ] Escape and a click outside both close the drawer
+- [ ] "Sync now" runs, the button disables while it does, and the counts
+ update afterwards
+- [ ] "Open qtmaildir" launches the client
+- [ ] `git status --short` in the repo is empty, and
+ `grep -rn '/home/' mail-overview/ README.md AGENTS.md` finds nothing
+- [ ] The user has looked at the drawer and is happy with how it renders