aboutsummaryrefslogtreecommitdiffstats
path: root/mail-overview
diff options
context:
space:
mode:
Diffstat (limited to 'mail-overview')
-rw-r--r--mail-overview/Accounts.qml195
-rw-r--r--mail-overview/Button.qml45
-rw-r--r--mail-overview/MailPanel.qml318
-rw-r--r--mail-overview/README.md254
l---------mail-overview/Theme.qml1
-rwxr-xr-xmail-overview/mail-notify.sh295
-rw-r--r--mail-overview/shell.qml24
-rwxr-xr-xmail-overview/test-mail-notify.sh148
-rwxr-xr-xmail-overview/waybar-mail.sh68
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("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
- ' 2>/dev/null)" || return 0
- [[ -n "$rowtext" ]] || return 0
-
- shown="$(printf '%s\n' "$rowtext" | wc -l)"
- body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')"
-
- printf '%s' "$body"
- if [[ "$total" -gt "$shown" ]]; then
- printf '\n+%d more' "$((total - shown))"
- fi
- printf '\n'
-}
-
-# The last revision this script notified up to, or empty when there is none
-# to trust. Empty means "seed silently": record where we are now and notify
-# nothing.
-#
-# The stored UUID is checked because notmuch revisions are only comparable
-# within one database. A rebuilt database restarts the counter, so an old
-# revision would be meaningless, and treating it as a floor would either
-# notify nothing forever or notify everything at once.
-read_prev_rev() {
- local want_uuid="$1" got_uuid rev
-
- [[ -f "$STATE" ]] || return 0
- read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
-
- [[ "$got_uuid" == "$want_uuid" ]] || return 0
- [[ "$rev" =~ ^[0-9]+$ ]] || return 0
-
- printf '%s' "$rev"
-}
-
-# Written by atomic replace, the same idiom mail-watcher uses for its
-# heartbeat: a reader must never see a half-written file, and mv within a
-# directory is atomic where a redirect into the final path is not.
-#
-# Failure to write is deliberately not fatal. The notifications have already
-# been sent; taking the watcher down over a failure to record that would turn
-# a bookkeeping problem into a no-mail-notifications problem.
-write_state() {
- local uuid="$1" rev="$2" tmp
-
- mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
- tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
- printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
- mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
- return 0
-}
-
-# Sends one notification for one account.
-#
-# dunstify rather than notify-send because actions need it. A stack tag per
-# account means a second batch for the same account replaces the first rather
-# than stacking, which is what "one notification per account" has to mean when
-# mail keeps arriving.
-#
-# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
-# on most dunst configurations critical notifications never expire, which
-# would leave mail popups on screen until clicked.
-#
-# The click cannot open the account it belongs to. qtmaildir accepts no
-# command line arguments and startup_account is a static config setting, not
-# a flag, which is the same limitation the drawer's thread rows already have.
-notify_account() {
- local label="$1" key="$2" count="$3" body="$4"
-
- # -a carries "New Mail" because the user's dunst format renders %a as the
- # bold heading line, with %s italic below it.
- if ! command -v dunstify >/dev/null 2>&1; then
- # No actions available, but a notification without a click is still
- # worth having.
- notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \
- "$label ($count)" "$body"
- return 0
- fi
-
- # Backgrounded because -b blocks until the notification is dismissed or
- # clicked. Without this the loop would stall for the full timeout on
- # every account, and a five-account batch would take most of a minute.
- (
- if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \
- -h "string:x-dunst-stack-tag:mail-$key" \
- -A "default,open" \
- "$label ($count)" "$body")" == "default" ]]; then
- "$HOME/bin/qtmaildir" &
- fi
- ) >/dev/null 2>&1 &
-}
-
-# One pass: what has arrived since the revision we last saw.
-tick() {
- local lastmod uuid cur prev
-
- # Three tab-separated fields: count, database UUID, revision. Verified on
- # notmuch 0.39.
- lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
- uuid="$(printf '%s' "$lastmod" | cut -f2)"
- cur="$(printf '%s' "$lastmod" | cut -f3)"
-
- # The output is the test, not the exit status. notmuch fails two ways and
- # only one is detectable: a rejected query prints nothing and exits 1,
- # while a query Xapian merely misparses returns a plausible wrong number
- # and exits 0. The defence against the second is that SCOPE is a fixed
- # string and is never built from anything.
- [[ "$cur" =~ ^[0-9]+$ ]] || return 0
- [[ -n "$uuid" ]] || return 0
-
- prev="$(read_prev_rev "$uuid")"
-
- # No trustworthy floor: record where we are and say nothing. This is the
- # first run, a rebuilt database, or a corrupt state file.
- if [[ -z "$prev" ]]; then
- write_state "$uuid" "$cur"
- return 0
- fi
-
- # Nothing committed since last time, or the counter went backwards.
- if [[ "$cur" -le "$prev" ]]; then
- return 0
- fi
-
- local key label query count rows
- while IFS=$'\t' read -r key label; do
- [[ -n "$key" ]] || continue
-
- query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur"
-
- count="$(notmuch count "$query" 2>/dev/null)"
- # Same validation, same reason: an empty string must not become a
- # zero, and a failure here skips this account rather than the batch.
- [[ "$count" =~ ^[0-9]+$ ]] || continue
- [[ "$count" -gt 0 ]] || continue
-
- rows="$(notmuch search --format=json --limit="$ROWS" \
- --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
-
- notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
- done < <(parse_accounts < "$CONFIG")
-
- # Written only after every account is done. A single account whose count
- # fails to validate is skipped above but does not hold the revision back;
- # leaving it behind would re-notify every successful account's range on
- # each later tick. Only a failed state write itself leaves prev unchanged.
- write_state "$uuid" "$cur"
-}
-
-main() {
- local db
- db="$(notmuch config get database.path 2>/dev/null)/xapian"
-
- if [[ ! -d "$db" ]]; then
- echo "mail-notify: no notmuch database at $db" >&2
- exit 1
- fi
-
- if [[ ! -r "$CONFIG" ]]; then
- echo "mail-notify: cannot read $CONFIG" >&2
- exit 1
- fi
-
- if [[ "${1:-}" == "--once" ]]; then
- tick
- return 0
- fi
-
- # Seed before watching, so a first run never notifies the backlog.
- tick
-
- # The watch is on the xapian DIRECTORY, not a file inside it: a commit
- # replaces files, and a watch held on a filename dies with the file.
- while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
- # One commit touches several files. Without this, a single sync fires
- # three or four ticks.
- sleep 0.3
- tick
- done
-
- # Falling out means inotifywait itself failed. Say so rather than exiting
- # silently, which is indistinguishable from no mail arriving.
- echo "mail-notify: inotify watch stopped" >&2
- exit 1
-}
-
-# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
-# loop. The bash equivalent of Python's __name__ == "__main__".
-[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
diff --git a/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 &amp; B — &lt;script&gt;" \
- "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
-
-check "a missing subject says so rather than printing nothing" \
- "• Alice Example — (no subject)" \
- "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
-
-check "malformed json yields an empty body rather than an error" \
- "" \
- "$(build_body 'not json at all' 1 2>/dev/null)"
-
-check "a newline in a subject stays on one row" \
- "• Alice Example — line one line two" \
- "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)"
-
-# A scratch state file, removed at exit. Never the real one: that is the
-# user's live notification position, and a test must not move it.
-tmpstate="$(mktemp)"
-trap 'rm -f "$tmpstate"' EXIT
-STATE="$tmpstate"
-
-rm -f "$tmpstate"
-check "a missing state file reports no previous revision" \
- "" "$(read_prev_rev "uuid-a")"
-
-write_state "uuid-a" 500
-check "a revision written is a revision read back" \
- "500" "$(read_prev_rev "uuid-a")"
-
-check "a different database UUID discards the revision" \
- "" "$(read_prev_rev "uuid-b")"
-
-printf 'uuid-a garbage\n' > "$tmpstate"
-check "an unparseable state file reports no previous revision" \
- "" "$(read_prev_rev "uuid-a")"
-
-write_state "uuid-a" 600
-check "a rewrite replaces rather than appends" \
- "1" "$(wc -l < "$tmpstate")"
-
-printf '\n%d passed, %d failed\n' "$pass" "$fail"
-[[ "$fail" -eq 0 ]]
diff --git a/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