diff options
Diffstat (limited to 'mail-overview')
| -rw-r--r-- | mail-overview/Accounts.qml | 195 | ||||
| -rw-r--r-- | mail-overview/Button.qml | 45 | ||||
| -rw-r--r-- | mail-overview/MailPanel.qml | 318 | ||||
| -rw-r--r-- | mail-overview/README.md | 254 | ||||
| l--------- | mail-overview/Theme.qml | 1 | ||||
| -rwxr-xr-x | mail-overview/mail-notify.sh | 295 | ||||
| -rw-r--r-- | mail-overview/shell.qml | 24 | ||||
| -rwxr-xr-x | mail-overview/test-mail-notify.sh | 148 | ||||
| -rwxr-xr-x | mail-overview/waybar-mail.sh | 68 |
9 files changed, 0 insertions, 1348 deletions
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml deleted file mode 100644 index 0ae6886..0000000 --- a/mail-overview/Accounts.qml +++ /dev/null @@ -1,195 +0,0 @@ -// 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/mail-overview/Button.qml b/mail-overview/Button.qml deleted file mode 100644 index d6ca78f..0000000 --- a/mail-overview/Button.qml +++ /dev/null @@ -1,45 +0,0 @@ -// 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/mail-overview/MailPanel.qml b/mail-overview/MailPanel.qml deleted file mode 100644 index 0a7f36a..0000000 --- a/mail-overview/MailPanel.qml +++ /dev/null @@ -1,318 +0,0 @@ -// 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 Quickshell.Wayland -import QtQuick - -Scope { - id: root - - // Waybar runs on this screen, so the drawer belongs here too. - property string monitor: "DP-1" - property bool open: false - - // mail-watcher's heartbeat, written every 60s. Read once per open: the - // drawer is a LazyLoader, so closing and reopening 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) { - root.watcherAlive = false; - root.watcherDead = 0; - root.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; - - root.watcherAlive = true; - root.watcherDead = Number(data.dead) || 0; - root.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` - - readonly property var screenObj: - Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] - - function show() { - // Mail has almost certainly arrived since this was last opened, and - // for an autostarted shell that is the whole session. - Accounts.refresh(); - root.open = true; - } - function close() { root.open = false; } - function toggle() { root.open ? close() : show(); } - - // Quickshell exits once no window is visible, and this drawer is closed - // most of the time. See AGENTS.md. - PanelWindow { - visible: true - implicitWidth: 1 - implicitHeight: 1 - color: "transparent" - exclusionMode: ExclusionMode.Ignore - mask: Region {} - WlrLayershell.keyboardFocus: WlrKeyboardFocus.None - } - - LazyLoader { - active: root.open - - PanelWindow { - id: win - screen: root.screenObj - - anchors { top: true; left: true; right: true; bottom: true } - color: "transparent" - - // Normal, not Ignore: waybar claims an exclusive zone at the top - // of this screen, so respecting it puts the drawer below the bar - // without this file knowing the bar's height. The backdrop then - // starts below waybar too, which is why waybar stays un-dimmed. - exclusionMode: ExclusionMode.Normal - - WlrLayershell.layer: WlrLayer.Overlay - WlrLayershell.namespace: "quickshell-mail" - WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive - - Rectangle { - anchors.fill: parent - color: "#000000" - opacity: 0.5 - MouseArea { anchors.fill: parent; onClicked: root.close() } - } - - // Keys reach a focused item, never the window itself: setting - // keyboardFocus above is necessary but not sufficient, and - // Keys.onEscapePressed on a PanelWindow never fires. - Item { - anchors.fill: parent - focus: true - Keys.onEscapePressed: root.close() - } - - // Top right, under the waybar icon, which sits in modules-right. - Rectangle { - id: drawer - anchors { top: parent.top; right: parent.right; topMargin: 8; rightMargin: 8 } - width: 460 - height: Math.min(content.implicitHeight + 32, win.height - 24) - radius: 14 - color: Qt.alpha(Theme.base, 0.72) - border.width: 1 - border.color: Qt.alpha(Theme.text, 0.12) - - // Swallows clicks so they do not reach the backdrop and close - // the drawer. - MouseArea { anchors.fill: parent } - - FileView { - id: heartbeat - path: `${Quickshell.env("HOME")}/.local/state/mail-watcher.heartbeat` - onLoaded: root.readHeartbeat(text()) - onLoadFailed: root.readHeartbeat("") - } - - Column { - id: content - anchors { left: parent.left; right: parent.right; top: parent.top; margins: 16 } - spacing: 10 - - Item { - width: parent.width - implicitHeight: Math.max(title.implicitHeight, totalText.implicitHeight) - - Text { - id: title - anchors.verticalCenter: parent.verticalCenter - text: "Mail" - color: Theme.text - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize + 2 - font.bold: true - } - - Text { - id: totalText - anchors { right: parent.right; verticalCenter: parent.verticalCenter } - // A dash rather than a possibly-wrong number while - // any account is still unknown. - text: Accounts.anyUnknown ? "—" : `${Accounts.total} unread` - color: Theme.subtext - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - } - - Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } - - Text { - visible: Accounts.error !== "" - width: parent.width - text: Accounts.error - color: Theme.red - wrapMode: Text.WordWrap - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - 2 - } - - Repeater { - model: Accounts.accounts - - Column { - required property var modelData - width: content.width - spacing: 4 - - Item { - width: parent.width - implicitHeight: 26 - - Rectangle { - id: dot - anchors.verticalCenter: parent.verticalCenter - width: 8; height: 8; radius: 4 - // The account's own colour from the config. - // Per-account identity, not a palette. - color: modelData.color || Theme.accent - } - - Text { - anchors { left: dot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } - text: modelData.label - color: Theme.text - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - } - - Text { - anchors { right: parent.right; verticalCenter: parent.verticalCenter } - text: modelData.count < 0 ? "—" : String(modelData.count) - color: modelData.count > 0 ? Theme.text : Theme.subtext - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - font.bold: modelData.count > 0 - } - } - - // The newest three unread threads. Read-only: - // qtmaildir takes no arguments, so there is no way - // to ask it for a particular thread. - Repeater { - model: modelData.threads - - Column { - required property var modelData - width: content.width - 18 - x: 18 - spacing: 1 - bottomPadding: 4 - - Item { - width: parent.width - implicitHeight: who.implicitHeight - - Text { - id: who - anchors.left: parent.left - width: parent.width - when.implicitWidth - 10 - text: modelData.authors - elide: Text.ElideRight - color: Theme.subtext - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - 3 - } - - Text { - id: when - anchors.right: parent.right - text: modelData.date - color: Theme.overlay - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - 3 - } - } - - Text { - width: parent.width - text: modelData.subject - elide: Text.ElideRight - color: Theme.text - font.family: Theme.fontFamily - font.pixelSize: Theme.fontSize - 2 - } - } - } - } - } - - Rectangle { width: parent.width; height: 1; color: Qt.alpha(Theme.text, 0.12) } - - // Watcher health, above the button. 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: root.watcherColor - } - - Text { - anchors { left: watcherDot.right; leftMargin: 10; verticalCenter: parent.verticalCenter } - text: root.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(); root.close(); } - } - } - } - } - } - } -} diff --git a/mail-overview/README.md b/mail-overview/README.md deleted file mode 100644 index 34eaa53..0000000 --- a/mail-overview/README.md +++ /dev/null @@ -1,254 +0,0 @@ -# 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/mail-overview/Theme.qml b/mail-overview/Theme.qml deleted file mode 120000 index 3d2e40f..0000000 --- a/mail-overview/Theme.qml +++ /dev/null @@ -1 +0,0 @@ -../shared/Theme.qml
\ No newline at end of file diff --git a/mail-overview/mail-notify.sh b/mail-overview/mail-notify.sh deleted file mode 100755 index b132775..0000000 --- a/mail-overview/mail-notify.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/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/mail-overview/shell.qml b/mail-overview/shell.qml deleted file mode 100644 index cd2e907..0000000 --- a/mail-overview/shell.qml +++ /dev/null @@ -1,24 +0,0 @@ -// 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 - -ShellRoot { - MailPanel { id: panel } - - IpcHandler { - target: "mail" - function toggle() { panel.toggle(); } - function show() { panel.show(); } - function close() { panel.close(); } - } -} diff --git a/mail-overview/test-mail-notify.sh b/mail-overview/test-mail-notify.sh deleted file mode 100755 index 1c3e01a..0000000 --- a/mail-overview/test-mail-notify.sh +++ /dev/null @@ -1,148 +0,0 @@ -#!/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/mail-overview/waybar-mail.sh b/mail-overview/waybar-mail.sh deleted file mode 100755 index ee07ecb..0000000 --- a/mail-overview/waybar-mail.sh +++ /dev/null @@ -1,68 +0,0 @@ -#!/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 mail-overview/README.md. - -set -u - -QUERY='tag:unread and tag:inbox' - -# notmuch knows where its own database is, so this follows a moved database -# without an edit here. -db="$(notmuch config get database.path 2>/dev/null)/xapian" - -emit() { - local n - n="$(notmuch count "$QUERY" 2>/dev/null)" - - # 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 |
