diff options
Diffstat (limited to 'docs')
| -rw-r--r-- | docs/superpowers/plans/2026-09-12-mail-overview.md | 1339 | ||||
| -rw-r--r-- | docs/superpowers/specs/2026-09-12-mail-overview-design.md | 37 |
2 files changed, 1363 insertions, 13 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 diff --git a/docs/superpowers/specs/2026-09-12-mail-overview-design.md b/docs/superpowers/specs/2026-09-12-mail-overview-design.md index e5f2fa2..a691d79 100644 --- a/docs/superpowers/specs/2026-09-12-mail-overview-design.md +++ b/docs/superpowers/specs/2026-09-12-mail-overview-design.md @@ -68,6 +68,7 @@ new mail and falls as mail is read, with no polling in either case. | `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 | @@ -161,17 +162,24 @@ 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. +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 @@ -235,8 +243,11 @@ 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 +- 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 |
