diff options
Diffstat (limited to 'desktop')
| -rw-r--r-- | desktop/Button.qml | 45 | ||||
| -rw-r--r-- | desktop/modules/mail/Accounts.qml | 195 | ||||
| -rw-r--r-- | desktop/modules/mail/MailModule.qml | 40 | ||||
| -rw-r--r-- | desktop/modules/mail/MailPage.qml | 220 | ||||
| -rw-r--r-- | desktop/modules/mail/MailTile.qml | 24 | ||||
| -rw-r--r-- | desktop/modules/mail/README.md | 254 | ||||
| -rwxr-xr-x | desktop/modules/mail/mail-notify.sh | 295 | ||||
| -rwxr-xr-x | desktop/modules/mail/test-mail-notify.sh | 148 | ||||
| -rwxr-xr-x | desktop/modules/mail/waybar-mail.sh | 68 | ||||
| -rw-r--r-- | desktop/shell.qml | 2 |
10 files changed, 1291 insertions, 0 deletions
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. <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 + +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. <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("&"; "&") | 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. <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 & B — <script>" \ + "$(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 diff --git a/desktop/shell.qml b/desktop/shell.qml index 2168a1b..d27583d 100644 --- a/desktop/shell.qml +++ b/desktop/shell.qml @@ -13,6 +13,7 @@ import Quickshell import Quickshell.Io import Quickshell.Wayland import "modules/appearance" +import "modules/mail" import "modules/sound" ShellRoot { @@ -32,6 +33,7 @@ ShellRoot { id: drawer modules: [ SoundModule {}, + MailModule {}, AppearanceModule {}, ] } |
