aboutsummaryrefslogtreecommitdiffstats
path: root/desktop/modules
diff options
context:
space:
mode:
Diffstat (limited to 'desktop/modules')
-rw-r--r--desktop/modules/mail/Accounts.qml195
-rw-r--r--desktop/modules/mail/MailModule.qml40
-rw-r--r--desktop/modules/mail/MailPage.qml220
-rw-r--r--desktop/modules/mail/MailTile.qml24
-rw-r--r--desktop/modules/mail/README.md254
-rwxr-xr-xdesktop/modules/mail/mail-notify.sh295
-rwxr-xr-xdesktop/modules/mail/test-mail-notify.sh148
-rwxr-xr-xdesktop/modules/mail/waybar-mail.sh68
8 files changed, 1244 insertions, 0 deletions
diff --git a/desktop/modules/mail/Accounts.qml b/desktop/modules/mail/Accounts.qml
new file mode 100644
index 0000000..0ae6886
--- /dev/null
+++ b/desktop/modules/mail/Accounts.qml
@@ -0,0 +1,195 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+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: a zero there would
+ // read as an empty inbox.
+ 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.
+ //
+ // A line walk rather than one regex over the whole file. The obvious
+ // pattern for a section body, "everything up to the next [", is wrong
+ // here: several accounts carry folder names like "[Gmail]/Bozze", so the
+ // body ended before its label and three of five accounts silently fell
+ // back to displaying their key. Walking lines says what it means, and the
+ // only state is which section we are in.
+ function parseAccounts(conf) {
+ const out = [];
+ let cur = null;
+
+ for (const line of conf.split("\n")) {
+ // 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 header = line.match(/^\[account\.([^\]]+)\]/);
+ if (header) {
+ cur = { key: header[1], label: "", color: "", count: -1, threads: [] };
+ out.push(cur);
+ continue;
+ }
+ // Any other section ends the current account.
+ if (line.startsWith("[")) {
+ cur = null;
+ continue;
+ }
+ if (!cur) continue;
+
+ const field = line.match(/^\s*(label|color)\s*=\s*(.+)$/);
+ if (field) cur[field[1]] = field[2].trim();
+ }
+
+ // A missing label shows the key, which is ugly but true.
+ for (const a of out) if (!a.label) a.label = a.key;
+ 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]);
+
+ // The output is the test, not the exit status: a query notmuch rejects
+ // prints nothing, and an empty string must not become a zero, which
+ // would read as "no new mail".
+ 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. It 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;
+ }
+}
diff --git a/desktop/modules/mail/MailModule.qml b/desktop/modules/mail/MailModule.qml
new file mode 100644
index 0000000..99ddaff
--- /dev/null
+++ b/desktop/modules/mail/MailModule.qml
@@ -0,0 +1,40 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+// Always active: the unread count outlives the drawer, and Accounts watches
+// qtmaildir.conf so a new account appears without a restart.
+Module {
+ id: mod
+
+ name: "mail"
+ icon: ""
+ label: "Mail"
+ alwaysActive: true
+
+ tileContent: Component { MailTile {} }
+
+ page: Component {
+ Page {
+ id: mailPage
+ title: "Mail"
+ // Mail has almost certainly arrived since this was last opened,
+ // and for an autostarted shell that is the whole session.
+ Component.onCompleted: Accounts.refresh()
+ MailPage {
+ width: parent.width
+ onClose: mailPage.back()
+ }
+ }
+ }
+}
diff --git a/desktop/modules/mail/MailPage.qml b/desktop/modules/mail/MailPage.qml
new file mode 100644
index 0000000..85b0876
--- /dev/null
+++ b/desktop/modules/mail/MailPage.qml
@@ -0,0 +1,220 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import Quickshell
+import Quickshell.Io
+import QtQuick
+import "../.."
+
+Column {
+ id: page
+
+ signal close
+
+ // mail-watcher's heartbeat, written every 60s. Read once per open: the
+ // page is behind a Loader, so opening it rebuilds the FileView and reads
+ // the current file. Watch is deliberately not used, because the heartbeat
+ // is written by atomic replace (tmpfile + rename) and an inotify watch
+ // held on the old inode dies with it.
+ property bool watcherAlive: false
+ property int watcherDead: 0
+ property int watcherExpected: 0
+
+ // The same staleness rule as mail-watcher's heartbeat_is_healthy: dead
+ // threads or a heartbeat older than 300s mean the watcher needs a look.
+ // Backoff is healthy, so it never turns the dot.
+ function readHeartbeat(payload) {
+ page.watcherAlive = false;
+ page.watcherDead = 0;
+ page.watcherExpected = 0;
+
+ let data = null;
+ try { data = JSON.parse(payload); } catch (e) { return; }
+ if (!data || typeof data.ts !== "string") return;
+
+ const ts = Date.parse(data.ts);
+ if (isNaN(ts) || (Date.now() - ts) / 1000 > 300) return;
+
+ page.watcherAlive = true;
+ page.watcherDead = Number(data.dead) || 0;
+ page.watcherExpected = Number(data.expected) || 0;
+ }
+
+ readonly property color watcherColor:
+ !watcherAlive ? Theme.red
+ : watcherDead > 0 ? Theme.yellow
+ : Theme.green
+
+ readonly property string watcherText:
+ !watcherAlive ? "watcher not running"
+ : watcherDead > 0 ? `${watcherDead} folder(s) dead, check the log`
+ : `watcher ok · ${watcherExpected} folders`
+
+ spacing: 10
+
+ FileView {
+ id: heartbeat
+ path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat`
+ onLoaded: page.readHeartbeat(text())
+ onLoadFailed: page.readHeartbeat("")
+ }
+
+ Item {
+ width: parent.width
+ implicitHeight: totalText.implicitHeight
+
+ Text {
+ id: totalText
+ anchors.right: parent.right
+ // 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
+ }
+ }
+
+ 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: page.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: page.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) }
+
+ // Watcher health. Green when idling, yellow when a folder gave up, red
+ // when there is no fresh heartbeat at all.
+ Item {
+ width: parent.width
+ implicitHeight: 22
+
+ Rectangle {
+ id: watcherDot
+ anchors.verticalCenter: parent.verticalCenter
+ width: 8; height: 8; radius: 4
+ color: page.watcherColor
+ }
+
+ Text {
+ anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter }
+ text: page.watcherText
+ color: Theme.subtext
+ 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: "Open qtmaildir"
+ onClicked: { Accounts.openClient(); page.close(); }
+ }
+ }
+}
diff --git a/desktop/modules/mail/MailTile.qml b/desktop/modules/mail/MailTile.qml
new file mode 100644
index 0000000..640dad0
--- /dev/null
+++ b/desktop/modules/mail/MailTile.qml
@@ -0,0 +1,24 @@
+// Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+//
+// This program is free software; you can redistribute it and/or modify
+// it under the terms of the GNU General Public License version 2 as
+// published by the Free Software Foundation.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+
+import QtQuick
+import "../.."
+
+Text {
+ elide: Text.ElideRight
+ font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 4 }
+ color: Accounts.total > 0 ? Theme.text : Theme.subtext
+ // A dash rather than a possibly-wrong number while any account is unknown,
+ // the same rule the page header uses.
+ text: Accounts.anyUnknown ? "—"
+ : Accounts.total === 0 ? "no unread"
+ : `${Accounts.total} unread`
+}
diff --git a/desktop/modules/mail/README.md b/desktop/modules/mail/README.md
new file mode 100644
index 0000000..34eaa53
--- /dev/null
+++ b/desktop/modules/mail/README.md
@@ -0,0 +1,254 @@
+# mail-overview
+
+Unread mail across every account, as one waybar number and a drawer behind it.
+Clicking the icon opens the drawer; Escape or a click outside closes it.
+
+ ┌──────────────────────────────────────────────────┐
+ │ Mail 33 unread │
+ ├──────────────────────────────────────────────────┤
+ │ ● Account A 2 │
+ │ Some Sender Today 06:18 │
+ │ [a-list] a subject line that is elided... │
+ ├──────────────────────────────────────────────────┤
+ │ ● Account B 0 │
+ │ ● Account C 31 │
+ │ ...three newest threads... │
+ │ ● Account D 0 │
+ │ ● Account E 0 │
+ ├──────────────────────────────────────────────────┤
+ │ ● watcher ok · 25 folders │
+ ├──────────────────────────────────────────────────┤
+ │ [Open qtmaildir] │
+ └──────────────────────────────────────────────────┘
+
+## Running it
+
+ qs -p .
+
+It is started from `autostart.lua` and reached over IPC, so the shell has to be
+running for the waybar click to do anything:
+
+ qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle
+
+Write that path out in full in the real config. Waybar's `exec` and `on-click`
+have no shell to expand `~`, and neither do Hyprland's Lua strings.
+
+## The accounts are not listed here
+
+`qtmaildir.conf` already has one `[account.<key>]` section per account, and
+`<key>` is exactly the suffix of the notmuch tag `account-<key>`, with a short
+`label` and a `color` alongside. The panel parses that file, in file order, so
+adding an account to qtmaildir makes it appear here with no edit to any QML.
+The file is watched, so that happens without a restart.
+
+The `color` is the account's own, from its config section. It marks the dot
+beside each row and nothing else: per-account identity is not a palette, and
+`Theme` still owns every other colour.
+
+**Parsing walks lines rather than matching one regex over the file.** The
+obvious pattern for a section body, everything up to the next `[`, is wrong
+here: several accounts have folders named like `[Gmail]/Bozze`, so the body
+ended at that bracket, before the `label` line, and three of five accounts
+quietly fell back to displaying their raw key. Nothing errored, because falling
+back is a legitimate path for an account with no label.
+
+## Counting
+
+Every count is `tag:unread and tag:inbox`. Plain `tag:unread` also counts
+archived-but-unread mail and mailing list traffic that was never in the inbox,
+which for one account here was the difference between 32 and 41; the
+inbox-scoped number is the one that means new mail worth looking at.
+
+**By the `account-*` tag, never by a `path:` glob.** qtmaildir itself scopes an
+account with `path:"<maildir>/**"`, and copying that query would be wrong for
+this panel. notmuch deduplicates by message id, so a message that arrived at
+two of the configured addresses is one message with two file paths: a glob
+counts it under both accounts, and the rows then sum to more than the total the
+waybar icon shows. Measured here, five accounts summed to 102 against a global
+total of 101. The tag is a property of the message, so it is singular and the
+rows always sum to the header. The cost is that such a message appears under
+only the account the `post-new` hook attributed it to, which is the right trade
+for an overview whose headline figure is a single number.
+
+## notmuch fails two ways, and only one is detectable
+
+A query notmuch rejects prints nothing and exits 1:
+
+ notmuch count 'tag:unread and ('
+
+A query Xapian merely misparses returns a plausible wrong number and exits 0:
+
+ notmuch count 'tag:unread and ((' # 41, exit 0
+ notmuch count 'tag:unread and tag:' # 3, exit 0
+
+So the exit status is not the test, and neither is any check that a wrong
+number could pass. Counts are validated as non-negative integers, which catches
+the first case, where empty output would otherwise render as an empty inbox.
+The second is not catchable; the defence against it is that the queries are
+fixed strings and are never built from anything.
+
+An unknown count shows a dash, never a zero, for the same reason the VM panel
+refuses to substitute libvirt's host-side figures: a number that is actually a
+failure reads as a fact.
+
+## The waybar module
+
+A `custom` module in **continuous mode**: the script never exits, prints one
+JSON line per database commit, and waybar redraws on each line. There is no
+`interval`, and no daemon to supervise, because waybar owns the process.
+
+ "custom/mail": {
+ "format": "<span font='18px'>󰇮</span> {}",
+ "return-type": "json",
+ "exec": "~/Programming/GIT/quickshell/mail-overview/waybar-mail.sh",
+ "on-click": "qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle",
+ "on-click-right": "~/bin/mailsync.sh",
+ "tooltip": false
+ }
+
+The count drops the moment mail is read in qtmaildir and rises the moment
+mbsync commits, because both write the database and the watcher sees either.
+
+Two details in that loop are load-bearing. The watch is on the **xapian
+directory**, not on a file inside it: a commit replaces files, and a watch held
+on a filename dies with it. And a short sleep coalesces the several writes of
+one commit, without which the module redraws three or four times per sync with
+intermediate counts.
+
+The old setup this replaces ran three copies of a python script polling the
+Gmail API with stored credentials, covered three of the five accounts, and
+opened Thunderbird. Everything it fetched over the network was already in the
+local index.
+
+## Notifications on arrival
+
+`mail-notify.sh` sends one notification per account when mail lands, with the
+newest three threads' sender and subject and a `+N more` line when the batch is
+bigger. Each message is a bullet on its own line with a blank line between,
+under a bold `New Mail` heading with the account and count as the second line.
+Started from `autostart.lua`, it runs for the whole session.
+
+ /home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
+
+It watches the same xapian directory the waybar module does, for the same
+reason and with the same 0.3s debounce. It is a **separate process rather
+than part of `waybar-mail.sh`**, which already has that edge: waybar owns
+that script's process, so a bar restart would stop notifications with nothing
+reporting it.
+
+**A commit is not the same as new mail.** Reading a message in qtmaildir drops
+its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
+revision counter instead:
+
+ notmuch count --lastmod 'tag:unread and tag:inbox'
+
+which prints count, database UUID and revision, tab separated. Each account is
+then asked what it gained in `lastmod:<prev+1>..<cur>`, the lower bound one
+revision after the stored one so it is exclusive. A count delta was
+rejected because it cannot name senders, and a `date:` watermark because
+`date:` is the message's own Date header: backdated mail would never notify
+and future-dated mail would notify forever.
+
+Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
+replace, holding the **UUID as well as the revision**. Revisions only compare
+within one database, so a rebuild restarts the counter and a stored revision
+from the old one means nothing.
+
+**Missing, corrupt or mismatched state seeds silently**, recording the current
+revision and notifying nothing. Without that floor, `lastmod:0..` matches every
+unread message ever: 101 of them here, which is a wall of popups at every
+login. The same applies to a restart, so mail that arrived while it was down is
+never notified. The waybar count is still right and the drawer still shows it,
+so nothing is lost but a stale popup.
+
+The notification is sent with dunstify. The icon is passed as an **absolute
+path** to the active icon theme's `mail-unread-multiple`, because this dunst
+resolves an icon *name* only through the `icon_path` in `dunstrc`, and that
+path holds no mail icon, so a name renders no icon at all. The click action
+(`-A default,open`) makes dunst prepend an `(A)` action indicator when
+`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle
+click (dunst's `do_action`) opens qtmaildir, but not the account it belongs
+to: `qtmaildir` takes no arguments and `startup_account` is a static setting
+rather than a flag, the same limitation the thread rows have.
+
+`test-mail-notify.sh` is the check. It sources the script as a library and
+asserts on the functions that only move text around, so it needs no mail and no
+notmuch:
+
+ ./test-mail-notify.sh
+
+## Geometry, and the one property that differs from the other panels
+
+The window is a fullscreen overlay with a dimmed backdrop and the drawer itself
+anchored to its top right, which is the idiom `vm-manager` and `appearance` both
+use and what gives click-outside-to-close and a focusable item for Escape.
+
+It differs from those two in one property: `exclusionMode` is `Normal`, not
+`Ignore`. Waybar claims an exclusive zone at the top of this screen, so
+respecting it places the overlay below the bar with no height constant here to
+drift. Measured: waybar at `y=-540 h=42`, this overlay at `y=-498 h=1038`,
+starting exactly where the bar ends. The backdrop therefore never dims waybar.
+
+**Key events reach a focused item, not a window.** Setting
+`WlrLayershell.keyboardFocus` is necessary but not sufficient, and
+`Keys.onEscapePressed` on a `PanelWindow` never fires, so the focus sits on an
+inner `Item`.
+
+## Thread rows are read-only
+
+Each account shows its three newest unread threads as author, relative date and
+subject. Clicking one launches qtmaildir and nothing more, because `qtmaildir`
+accepts no command line arguments: there is no way to tell it which thread to
+open. `startup_account` in its config is a static setting, not a flag, so
+"open on this account" is unavailable for the same reason. Marking read from
+here was rejected rather than deferred: it would write the database behind a
+possibly running client.
+
+"Open qtmaildir" launches the client. There is no "Sync now" button: the
+watcher triggers a sync the moment mail lands, the cron tick is the backstop,
+and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual
+pull.
+
+## The watcher status dot
+
+Below the accounts, above the button, a coloured dot reports whether
+`mail-watcher` is alive and sane, read from its heartbeat at
+`~/.local/state/mail-watcher.heartbeat`:
+
+- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads.
+- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder
+ gave up permanently.
+- **red** (`watcher not running`) heartbeat missing, unparseable, or older than
+ 300s.
+
+Backoff never turns the dot: it is normal recovery from a dropped IDLE
+connection, and the watcher's own health check treats it as healthy. The
+staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`),
+reimplemented here in a few lines rather than shelling out to
+`mail-watcher.py --status` on every open.
+
+The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing
+and reopening rebuilds the `FileView` and reads the file current. A file watch
+is deliberately not used: the heartbeat is written by atomic replace (tmpfile
+then rename), so an inotify watch held on the old inode dies with it, which is
+the same trap as watching a file inside the Xapian directory. The cost is that
+a drawer left open does not update until reopened, which for 60s heartbeat data
+is not worth a timer.
+
+## Theme and blur
+
+`Theme.qml` is the shared one: the palette comes from
+`~/.cache/wal/udt-palette.qml` and is watched. It is a fourth identical copy,
+which is a known loose end, not a palette to grow.
+
+Frosting is Hyprland's, matched on this window's namespace:
+
+ hl.layer_rule({
+ name = "blur-mail",
+ match = { namespace = "^(quickshell-mail)$" },
+ blur = true,
+ xray = false,
+ ignore_alpha = 0.1,
+ })
+
+Without the rule it still works, rendering flat translucent.
diff --git a/desktop/modules/mail/mail-notify.sh b/desktop/modules/mail/mail-notify.sh
new file mode 100755
index 0000000..b132775
--- /dev/null
+++ b/desktop/modules/mail/mail-notify.sh
@@ -0,0 +1,295 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Notifies when mail arrives: one notification per account per batch, naming
+# the newest senders and subjects.
+#
+# Watches the notmuch Xapian directory and, on each commit, asks notmuch what
+# changed since the revision it last saw. A commit is NOT the same as new
+# mail, since reading and tagging also commit, which is why the revision
+# counter does the work rather than a count delta.
+#
+# This is its own process rather than part of waybar-mail.sh, which already
+# has the same arrival edge: waybar owns that process, so a bar restart would
+# stop notifications with nothing reporting it.
+#
+# Usage:
+# mail-notify.sh watch forever (what autostart runs)
+# mail-notify.sh --once process one tick and exit (for manual verification, one tick)
+
+set -u
+
+STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
+CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
+SCOPE='tag:unread and tag:inbox'
+
+# How many threads a notification body lists before eliding into "+N more".
+# Three matches the drawer's own --limit=3.
+ROWS=3
+
+# dunst here resolves a themed icon NAME only through its icon_path, which
+# holds no mail icon, so a name renders nothing (dunst stores an empty
+# icon_path for it). Pass an absolute path, as this machine's other
+# notifiers do. This is the icon the user picked; ${XDG_DATA_HOME} keeps a
+# home path out of the committed file.
+MAIL_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg"
+
+# Accounts in file order, one "key<TAB>label" line each, read from stdin.
+#
+# Two things here are load-bearing, and both have already broken this
+# component once:
+#
+# The key runs to the closing bracket, NOT to the first dot. Real keys
+# contain dots, so splitting on the first one yields a notmuch tag matching
+# nothing and an account that silently never notifies.
+#
+# And this walks lines rather than matching a section body as "everything up
+# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends
+# the body before its label and makes the account display its raw key.
+parse_accounts() {
+ local line key label
+ key=""
+ label=""
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key="${BASH_REMATCH[1]}"
+ label=""
+ continue
+ fi
+ # Any other section ends the current account.
+ if [[ "$line" =~ ^\[ ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key=""
+ label=""
+ continue
+ fi
+ [[ -n "$key" ]] || continue
+ if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ label="${BASH_REMATCH[1]}"
+ # Trailing whitespace only; a label may contain spaces.
+ label="${label%"${label##*[![:space:]]}"}"
+ fi
+ done
+
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ return 0
+}
+
+# Renders notmuch search JSON into notification body text.
+# $1 the JSON array from `notmuch search --format=json`
+# $2 the true total for this batch, which may exceed the rows present
+#
+# dunst has body-markup in its capabilities, so a subject containing < or &
+# would be parsed as markup and could vanish from the notification. Subjects
+# are attacker-controlled text arriving from the internet, so the three XML
+# characters are escaped here. This is the one place in this script where
+# untrusted text reaches a renderer.
+#
+# Malformed JSON prints nothing and succeeds. A notification with no body is
+# still worth sending: the summary already carries the account and the count.
+build_body() {
+ local json="$1" total="$2" shown rowtext body
+
+ rowtext="$(printf '%s' "$json" | jq -r '
+ .[] | "• " + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
+ | gsub("[\r\n]+"; " ")
+ | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
+ ' 2>/dev/null)" || return 0
+ [[ -n "$rowtext" ]] || return 0
+
+ shown="$(printf '%s\n' "$rowtext" | wc -l)"
+ body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')"
+
+ printf '%s' "$body"
+ if [[ "$total" -gt "$shown" ]]; then
+ printf '\n+%d more' "$((total - shown))"
+ fi
+ printf '\n'
+}
+
+# The last revision this script notified up to, or empty when there is none
+# to trust. Empty means "seed silently": record where we are now and notify
+# nothing.
+#
+# The stored UUID is checked because notmuch revisions are only comparable
+# within one database. A rebuilt database restarts the counter, so an old
+# revision would be meaningless, and treating it as a floor would either
+# notify nothing forever or notify everything at once.
+read_prev_rev() {
+ local want_uuid="$1" got_uuid rev
+
+ [[ -f "$STATE" ]] || return 0
+ read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
+
+ [[ "$got_uuid" == "$want_uuid" ]] || return 0
+ [[ "$rev" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s' "$rev"
+}
+
+# Written by atomic replace, the same idiom mail-watcher uses for its
+# heartbeat: a reader must never see a half-written file, and mv within a
+# directory is atomic where a redirect into the final path is not.
+#
+# Failure to write is deliberately not fatal. The notifications have already
+# been sent; taking the watcher down over a failure to record that would turn
+# a bookkeeping problem into a no-mail-notifications problem.
+write_state() {
+ local uuid="$1" rev="$2" tmp
+
+ mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
+ tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
+ printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
+ mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
+ return 0
+}
+
+# Sends one notification for one account.
+#
+# dunstify rather than notify-send because actions need it. A stack tag per
+# account means a second batch for the same account replaces the first rather
+# than stacking, which is what "one notification per account" has to mean when
+# mail keeps arriving.
+#
+# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
+# on most dunst configurations critical notifications never expire, which
+# would leave mail popups on screen until clicked.
+#
+# The click cannot open the account it belongs to. qtmaildir accepts no
+# command line arguments and startup_account is a static config setting, not
+# a flag, which is the same limitation the drawer's thread rows already have.
+notify_account() {
+ local label="$1" key="$2" count="$3" body="$4"
+
+ # -a carries "New Mail" because the user's dunst format renders %a as the
+ # bold heading line, with %s italic below it.
+ if ! command -v dunstify >/dev/null 2>&1; then
+ # No actions available, but a notification without a click is still
+ # worth having.
+ notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \
+ "$label ($count)" "$body"
+ return 0
+ fi
+
+ # Backgrounded because -b blocks until the notification is dismissed or
+ # clicked. Without this the loop would stall for the full timeout on
+ # every account, and a five-account batch would take most of a minute.
+ (
+ if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \
+ -h "string:x-dunst-stack-tag:mail-$key" \
+ -A "default,open" \
+ "$label ($count)" "$body")" == "default" ]]; then
+ "$HOME/bin/qtmaildir" &
+ fi
+ ) >/dev/null 2>&1 &
+}
+
+# One pass: what has arrived since the revision we last saw.
+tick() {
+ local lastmod uuid cur prev
+
+ # Three tab-separated fields: count, database UUID, revision. Verified on
+ # notmuch 0.39.
+ lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
+ uuid="$(printf '%s' "$lastmod" | cut -f2)"
+ cur="$(printf '%s' "$lastmod" | cut -f3)"
+
+ # The output is the test, not the exit status. notmuch fails two ways and
+ # only one is detectable: a rejected query prints nothing and exits 1,
+ # while a query Xapian merely misparses returns a plausible wrong number
+ # and exits 0. The defence against the second is that SCOPE is a fixed
+ # string and is never built from anything.
+ [[ "$cur" =~ ^[0-9]+$ ]] || return 0
+ [[ -n "$uuid" ]] || return 0
+
+ prev="$(read_prev_rev "$uuid")"
+
+ # No trustworthy floor: record where we are and say nothing. This is the
+ # first run, a rebuilt database, or a corrupt state file.
+ if [[ -z "$prev" ]]; then
+ write_state "$uuid" "$cur"
+ return 0
+ fi
+
+ # Nothing committed since last time, or the counter went backwards.
+ if [[ "$cur" -le "$prev" ]]; then
+ return 0
+ fi
+
+ local key label query count rows
+ while IFS=$'\t' read -r key label; do
+ [[ -n "$key" ]] || continue
+
+ query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur"
+
+ count="$(notmuch count "$query" 2>/dev/null)"
+ # Same validation, same reason: an empty string must not become a
+ # zero, and a failure here skips this account rather than the batch.
+ [[ "$count" =~ ^[0-9]+$ ]] || continue
+ [[ "$count" -gt 0 ]] || continue
+
+ rows="$(notmuch search --format=json --limit="$ROWS" \
+ --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
+
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ done < <(parse_accounts < "$CONFIG")
+
+ # Written only after every account is done. A single account whose count
+ # fails to validate is skipped above but does not hold the revision back;
+ # leaving it behind would re-notify every successful account's range on
+ # each later tick. Only a failed state write itself leaves prev unchanged.
+ write_state "$uuid" "$cur"
+}
+
+main() {
+ local db
+ db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+ if [[ ! -d "$db" ]]; then
+ echo "mail-notify: no notmuch database at $db" >&2
+ exit 1
+ fi
+
+ if [[ ! -r "$CONFIG" ]]; then
+ echo "mail-notify: cannot read $CONFIG" >&2
+ exit 1
+ fi
+
+ if [[ "${1:-}" == "--once" ]]; then
+ tick
+ return 0
+ fi
+
+ # Seed before watching, so a first run never notifies the backlog.
+ tick
+
+ # The watch is on the xapian DIRECTORY, not a file inside it: a commit
+ # replaces files, and a watch held on a filename dies with the file.
+ while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this, a single sync fires
+ # three or four ticks.
+ sleep 0.3
+ tick
+ done
+
+ # Falling out means inotifywait itself failed. Say so rather than exiting
+ # silently, which is indistinguishable from no mail arriving.
+ echo "mail-notify: inotify watch stopped" >&2
+ exit 1
+}
+
+# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
+# loop. The bash equivalent of Python's __name__ == "__main__".
+[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
diff --git a/desktop/modules/mail/test-mail-notify.sh b/desktop/modules/mail/test-mail-notify.sh
new file mode 100755
index 0000000..1c3e01a
--- /dev/null
+++ b/desktop/modules/mail/test-mail-notify.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for mail-notify.sh. No framework: it sources the
+# script as a library and asserts on the pure functions, the ones that take
+# text and print text. Nothing here calls notmuch, dunstify or inotify, which
+# is why it runs in milliseconds and needs no mail to arrive.
+#
+# Usage: ./test-mail-notify.sh (exit 0 = all passed)
+
+set -u
+
+MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh"
+
+pass=0
+fail=0
+
+# Compares two strings and reports. Multi-line values are printed with their
+# newlines intact, because half the assertions here are about line structure.
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got"
+ fi
+}
+
+check "library guard does not run main" "loaded" "loaded"
+
+# A config with the two traps in it: a key containing dots, and a folder
+# value containing a bracket. Both are real shapes from qtmaildir.conf, with
+# placeholder names.
+read -r -d '' fixture <<'EOF'
+[general]
+theme = dark
+
+[account.simple]
+label = Simple
+color = #112233
+
+[account.provider-first.last]
+folder = [Gmail]/Bozze
+label = Dotted
+color = #445566
+
+[account.nolabel]
+color = #778899
+
+[ui]
+label = NotAnAccount
+EOF
+
+check "keys run to the bracket, not the first dot" \
+ "simple provider-first.last nolabel" \
+ "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')"
+
+check "a bracket in a value does not end the section" \
+ "Dotted" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')"
+
+check "a missing label falls back to the key" \
+ "nolabel" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')"
+
+check "a non-account section is not an account" \
+ "" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')"
+
+# The shape notmuch search --format=json actually returns, trimmed to the two
+# fields the body uses.
+rows_json='[
+ {"authors":"Alice Example","subject":"First subject"},
+ {"authors":"Bob Example","subject":"Second subject"},
+ {"authors":"Carol Example","subject":"Third subject"}
+]'
+
+check "a body lists author and subject per row" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject" \
+ "$(build_body "$rows_json" 3)"
+
+check "a batch bigger than the rows shown is elided" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject
++7 more" \
+ "$(build_body "$rows_json" 10)"
+
+check "markup characters are escaped, not rendered" \
+ "• A &amp; B — &lt;script&gt;" \
+ "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
+
+check "a missing subject says so rather than printing nothing" \
+ "• Alice Example — (no subject)" \
+ "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
+
+check "malformed json yields an empty body rather than an error" \
+ "" \
+ "$(build_body 'not json at all' 1 2>/dev/null)"
+
+check "a newline in a subject stays on one row" \
+ "• Alice Example — line one line two" \
+ "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)"
+
+# A scratch state file, removed at exit. Never the real one: that is the
+# user's live notification position, and a test must not move it.
+tmpstate="$(mktemp)"
+trap 'rm -f "$tmpstate"' EXIT
+STATE="$tmpstate"
+
+rm -f "$tmpstate"
+check "a missing state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 500
+check "a revision written is a revision read back" \
+ "500" "$(read_prev_rev "uuid-a")"
+
+check "a different database UUID discards the revision" \
+ "" "$(read_prev_rev "uuid-b")"
+
+printf 'uuid-a garbage\n' > "$tmpstate"
+check "an unparseable state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 600
+check "a rewrite replaces rather than appends" \
+ "1" "$(wc -l < "$tmpstate")"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]
diff --git a/desktop/modules/mail/waybar-mail.sh b/desktop/modules/mail/waybar-mail.sh
new file mode 100755
index 0000000..4942f54
--- /dev/null
+++ b/desktop/modules/mail/waybar-mail.sh
@@ -0,0 +1,68 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# 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 modules/mail/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)"
+
+ # The output is the test, not the exit status. notmuch fails two different
+ # ways: a rejected query prints nothing and exits 1, while a query Xapian
+ # merely misparses ('tag:unread and ((') returns a plausible wrong number
+ # and exits 0. Only the first is detectable, and it is the one that matters
+ # here, because empty output would otherwise render as "no new mail". The
+ # second is why QUERY is a fixed string and not built from anything.
+ 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