From d513418a2e8854bfe8702577cda6db2cbf0ce292 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 14 Sep 2026 12:27:41 +0200 Subject: feat(desktop): move mail-overview in as the mail module Accounts.qml is unchanged and MailPanel's body becomes the page. The three scripts move with it, which changes the absolute paths in autostart and in the waybar module; both are outside this repo and listed in the plan's final task. Button.qml, byte-identical here and in vm-manager, lands at the shell root as the single copy. --- desktop/Button.qml | 45 +++++ desktop/modules/mail/Accounts.qml | 195 +++++++++++++++++++ desktop/modules/mail/MailModule.qml | 40 ++++ desktop/modules/mail/MailPage.qml | 220 +++++++++++++++++++++ desktop/modules/mail/MailTile.qml | 24 +++ desktop/modules/mail/README.md | 254 ++++++++++++++++++++++++ desktop/modules/mail/mail-notify.sh | 295 ++++++++++++++++++++++++++++ desktop/modules/mail/test-mail-notify.sh | 148 ++++++++++++++ desktop/modules/mail/waybar-mail.sh | 68 +++++++ desktop/shell.qml | 2 + mail-overview/Accounts.qml | 195 ------------------- mail-overview/Button.qml | 45 ----- mail-overview/MailPanel.qml | 318 ------------------------------- mail-overview/README.md | 254 ------------------------ mail-overview/Theme.qml | 1 - mail-overview/mail-notify.sh | 295 ---------------------------- mail-overview/shell.qml | 24 --- mail-overview/test-mail-notify.sh | 148 -------------- mail-overview/waybar-mail.sh | 68 ------- 19 files changed, 1291 insertions(+), 1348 deletions(-) create mode 100644 desktop/Button.qml create mode 100644 desktop/modules/mail/Accounts.qml create mode 100644 desktop/modules/mail/MailModule.qml create mode 100644 desktop/modules/mail/MailPage.qml create mode 100644 desktop/modules/mail/MailTile.qml create mode 100644 desktop/modules/mail/README.md create mode 100755 desktop/modules/mail/mail-notify.sh create mode 100755 desktop/modules/mail/test-mail-notify.sh create mode 100755 desktop/modules/mail/waybar-mail.sh delete mode 100644 mail-overview/Accounts.qml delete mode 100644 mail-overview/Button.qml delete mode 100644 mail-overview/MailPanel.qml delete mode 100644 mail-overview/README.md delete mode 120000 mail-overview/Theme.qml delete mode 100755 mail-overview/mail-notify.sh delete mode 100644 mail-overview/shell.qml delete mode 100755 mail-overview/test-mail-notify.sh delete mode 100755 mail-overview/waybar-mail.sh diff --git a/desktop/Button.qml b/desktop/Button.qml new file mode 100644 index 0000000..d6ca78f --- /dev/null +++ b/desktop/Button.qml @@ -0,0 +1,45 @@ +// Copyright (C) 2026 Danilo M. +// +// 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 + +Rectangle { + id: btn + property alias text: label.text + property bool danger: false + property bool enabled: true + signal clicked + + implicitWidth: label.implicitWidth + 22 + implicitHeight: label.implicitHeight + 12 + radius: 7 + opacity: enabled ? 1 : 0.4 + color: area.containsMouse && enabled + ? Qt.alpha(danger ? Theme.red : Theme.accent, 0.25) + : Qt.alpha(Theme.surface, 0.8) + border.width: 1 + border.color: Qt.alpha(danger ? Theme.red : Theme.text, 0.2) + + Text { + id: label + anchors.centerIn: parent + font { family: Theme.fontFamily; pixelSize: Theme.fontSize - 2 } + color: danger ? Theme.red : Theme.text + } + + MouseArea { + id: area + anchors.fill: parent + hoverEnabled: true + cursorShape: btn.enabled ? Qt.PointingHandCursor : Qt.ArrowCursor + onClicked: if (btn.enabled) btn.clicked() + } +} 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. +// +// 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.] section per account, where is exactly the suffix of +// the notmuch tag account-, 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. +// +// 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. +// +// 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. +// +// 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.]` section per account, and +`` is exactly the suffix of the notmuch tag `account-`, 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:"/**"`, 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": "󰇮 {}", + "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:..`, 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. +# +# 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 "keylabel" 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("&"; "&") | gsub("<"; "<") | gsub(">"; ">") + ' 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. +# +# 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 & B — <script>" \ + "$(build_body '[{"authors":"A & B","subject":"