aboutsummaryrefslogtreecommitdiffstats
path: root/mail-overview
diff options
context:
space:
mode:
Diffstat (limited to 'mail-overview')
-rw-r--r--mail-overview/Accounts.qml24
-rw-r--r--mail-overview/MailPanel.qml78
-rw-r--r--mail-overview/README.md93
-rwxr-xr-xmail-overview/mail-notify.sh295
-rwxr-xr-xmail-overview/test-mail-notify.sh148
5 files changed, 607 insertions, 31 deletions
diff --git a/mail-overview/Accounts.qml b/mail-overview/Accounts.qml
index 27cdcf9..0ae6886 100644
--- a/mail-overview/Accounts.qml
+++ b/mail-overview/Accounts.qml
@@ -184,32 +184,12 @@ Singleton {
accounts = next;
}
- // Launching qtmaildir, and syncing. qtmaildir takes no arguments, so
- // there is nothing to tell it about the account or thread clicked.
+ // 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;
}
-
- property bool syncing: false
-
- // mailsync.sh is already lock-protected against a concurrent cron run, so
- // this does not need its own guard beyond not stacking clicks.
- Process {
- id: syncProc
- command: [`${Quickshell.env("HOME")}/bin/mailsync.sh`]
- onExited: {
- root.syncing = false;
- root.refresh();
- }
- }
-
- function sync() {
- if (syncing) return;
- syncing = true;
- syncProc.running = false;
- syncProc.running = true;
- }
}
diff --git a/mail-overview/MailPanel.qml b/mail-overview/MailPanel.qml
index 7457fb2..0a7f36a 100644
--- a/mail-overview/MailPanel.qml
+++ b/mail-overview/MailPanel.qml
@@ -10,6 +10,7 @@
// GNU General Public License for more details.
import Quickshell
+import Quickshell.Io
import Quickshell.Wayland
import QtQuick
@@ -20,6 +21,45 @@ Scope {
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]
@@ -95,6 +135,13 @@ Scope {
// 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 }
@@ -230,17 +277,36 @@ Scope {
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: Accounts.syncing ? "Syncing..." : "Sync now"
- enabled: !Accounts.syncing
- onClicked: Accounts.sync()
- }
-
- Button {
text: "Open qtmaildir"
onClicked: { Accounts.openClient(); root.close(); }
}
diff --git a/mail-overview/README.md b/mail-overview/README.md
index daf2bf9..34eaa53 100644
--- a/mail-overview/README.md
+++ b/mail-overview/README.md
@@ -16,7 +16,9 @@ Clicking the icon opens the drawer; Escape or a click outside closes it.
│ ● Account D 0 │
│ ● Account E 0 │
├──────────────────────────────────────────────────┤
- │ [Sync now] [Open qtmaildir] │
+ │ ● watcher ok · 25 folders │
+ ├──────────────────────────────────────────────────┤
+ │ [Open qtmaildir] │
└──────────────────────────────────────────────────┘
## Running it
@@ -118,6 +120,63 @@ 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
@@ -145,8 +204,36 @@ open. `startup_account` in its config is a static setting, not a flag, so
here was rejected rather than deferred: it would write the database behind a
possibly running client.
-"Sync now" runs `~/bin/mailsync.sh`, which is already lock-protected against a
-concurrent cron run, and the watcher picks up whatever it commits.
+"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
diff --git a/mail-overview/mail-notify.sh b/mail-overview/mail-notify.sh
new file mode 100755
index 0000000..b132775
--- /dev/null
+++ b/mail-overview/mail-notify.sh
@@ -0,0 +1,295 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Notifies when mail arrives: one notification per account per batch, naming
+# the newest senders and subjects.
+#
+# Watches the notmuch Xapian directory and, on each commit, asks notmuch what
+# changed since the revision it last saw. A commit is NOT the same as new
+# mail, since reading and tagging also commit, which is why the revision
+# counter does the work rather than a count delta.
+#
+# This is its own process rather than part of waybar-mail.sh, which already
+# has the same arrival edge: waybar owns that process, so a bar restart would
+# stop notifications with nothing reporting it.
+#
+# Usage:
+# mail-notify.sh watch forever (what autostart runs)
+# mail-notify.sh --once process one tick and exit (for manual verification, one tick)
+
+set -u
+
+STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}"
+CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}"
+SCOPE='tag:unread and tag:inbox'
+
+# How many threads a notification body lists before eliding into "+N more".
+# Three matches the drawer's own --limit=3.
+ROWS=3
+
+# dunst here resolves a themed icon NAME only through its icon_path, which
+# holds no mail icon, so a name renders nothing (dunst stores an empty
+# icon_path for it). Pass an absolute path, as this machine's other
+# notifiers do. This is the icon the user picked; ${XDG_DATA_HOME} keeps a
+# home path out of the committed file.
+MAIL_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg"
+
+# Accounts in file order, one "key<TAB>label" line each, read from stdin.
+#
+# Two things here are load-bearing, and both have already broken this
+# component once:
+#
+# The key runs to the closing bracket, NOT to the first dot. Real keys
+# contain dots, so splitting on the first one yields a notmuch tag matching
+# nothing and an account that silently never notifies.
+#
+# And this walks lines rather than matching a section body as "everything up
+# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends
+# the body before its label and makes the account display its raw key.
+parse_accounts() {
+ local line key label
+ key=""
+ label=""
+
+ while IFS= read -r line || [[ -n "$line" ]]; do
+ if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key="${BASH_REMATCH[1]}"
+ label=""
+ continue
+ fi
+ # Any other section ends the current account.
+ if [[ "$line" =~ ^\[ ]]; then
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ key=""
+ label=""
+ continue
+ fi
+ [[ -n "$key" ]] || continue
+ if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then
+ label="${BASH_REMATCH[1]}"
+ # Trailing whitespace only; a label may contain spaces.
+ label="${label%"${label##*[![:space:]]}"}"
+ fi
+ done
+
+ [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}"
+ return 0
+}
+
+# Renders notmuch search JSON into notification body text.
+# $1 the JSON array from `notmuch search --format=json`
+# $2 the true total for this batch, which may exceed the rows present
+#
+# dunst has body-markup in its capabilities, so a subject containing < or &
+# would be parsed as markup and could vanish from the notification. Subjects
+# are attacker-controlled text arriving from the internet, so the three XML
+# characters are escaped here. This is the one place in this script where
+# untrusted text reaches a renderer.
+#
+# Malformed JSON prints nothing and succeeds. A notification with no body is
+# still worth sending: the summary already carries the account and the count.
+build_body() {
+ local json="$1" total="$2" shown rowtext body
+
+ rowtext="$(printf '%s' "$json" | jq -r '
+ .[] | "• " + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
+ | gsub("[\r\n]+"; " ")
+ | gsub("&"; "&amp;") | gsub("<"; "&lt;") | gsub(">"; "&gt;")
+ ' 2>/dev/null)" || return 0
+ [[ -n "$rowtext" ]] || return 0
+
+ shown="$(printf '%s\n' "$rowtext" | wc -l)"
+ body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')"
+
+ printf '%s' "$body"
+ if [[ "$total" -gt "$shown" ]]; then
+ printf '\n+%d more' "$((total - shown))"
+ fi
+ printf '\n'
+}
+
+# The last revision this script notified up to, or empty when there is none
+# to trust. Empty means "seed silently": record where we are now and notify
+# nothing.
+#
+# The stored UUID is checked because notmuch revisions are only comparable
+# within one database. A rebuilt database restarts the counter, so an old
+# revision would be meaningless, and treating it as a floor would either
+# notify nothing forever or notify everything at once.
+read_prev_rev() {
+ local want_uuid="$1" got_uuid rev
+
+ [[ -f "$STATE" ]] || return 0
+ read -r got_uuid rev < "$STATE" 2>/dev/null || return 0
+
+ [[ "$got_uuid" == "$want_uuid" ]] || return 0
+ [[ "$rev" =~ ^[0-9]+$ ]] || return 0
+
+ printf '%s' "$rev"
+}
+
+# Written by atomic replace, the same idiom mail-watcher uses for its
+# heartbeat: a reader must never see a half-written file, and mv within a
+# directory is atomic where a redirect into the final path is not.
+#
+# Failure to write is deliberately not fatal. The notifications have already
+# been sent; taking the watcher down over a failure to record that would turn
+# a bookkeeping problem into a no-mail-notifications problem.
+write_state() {
+ local uuid="$1" rev="$2" tmp
+
+ mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0
+ tmp="$(mktemp "${STATE}.XXXXXX")" || return 0
+ printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; }
+ mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp"
+ return 0
+}
+
+# Sends one notification for one account.
+#
+# dunstify rather than notify-send because actions need it. A stack tag per
+# account means a second batch for the same account replaces the first rather
+# than stacking, which is what "one notification per account" has to mean when
+# mail keeps arriving.
+#
+# Normal urgency and an explicit 10s timeout, deliberately not -u critical:
+# on most dunst configurations critical notifications never expire, which
+# would leave mail popups on screen until clicked.
+#
+# The click cannot open the account it belongs to. qtmaildir accepts no
+# command line arguments and startup_account is a static config setting, not
+# a flag, which is the same limitation the drawer's thread rows already have.
+notify_account() {
+ local label="$1" key="$2" count="$3" body="$4"
+
+ # -a carries "New Mail" because the user's dunst format renders %a as the
+ # bold heading line, with %s italic below it.
+ if ! command -v dunstify >/dev/null 2>&1; then
+ # No actions available, but a notification without a click is still
+ # worth having.
+ notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \
+ "$label ($count)" "$body"
+ return 0
+ fi
+
+ # Backgrounded because -b blocks until the notification is dismissed or
+ # clicked. Without this the loop would stall for the full timeout on
+ # every account, and a five-account batch would take most of a minute.
+ (
+ if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \
+ -h "string:x-dunst-stack-tag:mail-$key" \
+ -A "default,open" \
+ "$label ($count)" "$body")" == "default" ]]; then
+ "$HOME/bin/qtmaildir" &
+ fi
+ ) >/dev/null 2>&1 &
+}
+
+# One pass: what has arrived since the revision we last saw.
+tick() {
+ local lastmod uuid cur prev
+
+ # Three tab-separated fields: count, database UUID, revision. Verified on
+ # notmuch 0.39.
+ lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0
+ uuid="$(printf '%s' "$lastmod" | cut -f2)"
+ cur="$(printf '%s' "$lastmod" | cut -f3)"
+
+ # The output is the test, not the exit status. notmuch fails two ways and
+ # only one is detectable: a rejected query prints nothing and exits 1,
+ # while a query Xapian merely misparses returns a plausible wrong number
+ # and exits 0. The defence against the second is that SCOPE is a fixed
+ # string and is never built from anything.
+ [[ "$cur" =~ ^[0-9]+$ ]] || return 0
+ [[ -n "$uuid" ]] || return 0
+
+ prev="$(read_prev_rev "$uuid")"
+
+ # No trustworthy floor: record where we are and say nothing. This is the
+ # first run, a rebuilt database, or a corrupt state file.
+ if [[ -z "$prev" ]]; then
+ write_state "$uuid" "$cur"
+ return 0
+ fi
+
+ # Nothing committed since last time, or the counter went backwards.
+ if [[ "$cur" -le "$prev" ]]; then
+ return 0
+ fi
+
+ local key label query count rows
+ while IFS=$'\t' read -r key label; do
+ [[ -n "$key" ]] || continue
+
+ query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur"
+
+ count="$(notmuch count "$query" 2>/dev/null)"
+ # Same validation, same reason: an empty string must not become a
+ # zero, and a failure here skips this account rather than the batch.
+ [[ "$count" =~ ^[0-9]+$ ]] || continue
+ [[ "$count" -gt 0 ]] || continue
+
+ rows="$(notmuch search --format=json --limit="$ROWS" \
+ --sort=newest-first "$query" 2>/dev/null)" || rows="[]"
+
+ notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")"
+ done < <(parse_accounts < "$CONFIG")
+
+ # Written only after every account is done. A single account whose count
+ # fails to validate is skipped above but does not hold the revision back;
+ # leaving it behind would re-notify every successful account's range on
+ # each later tick. Only a failed state write itself leaves prev unchanged.
+ write_state "$uuid" "$cur"
+}
+
+main() {
+ local db
+ db="$(notmuch config get database.path 2>/dev/null)/xapian"
+
+ if [[ ! -d "$db" ]]; then
+ echo "mail-notify: no notmuch database at $db" >&2
+ exit 1
+ fi
+
+ if [[ ! -r "$CONFIG" ]]; then
+ echo "mail-notify: cannot read $CONFIG" >&2
+ exit 1
+ fi
+
+ if [[ "${1:-}" == "--once" ]]; then
+ tick
+ return 0
+ fi
+
+ # Seed before watching, so a first run never notifies the backlog.
+ tick
+
+ # The watch is on the xapian DIRECTORY, not a file inside it: a commit
+ # replaces files, and a watch held on a filename dies with the file.
+ while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do
+ # One commit touches several files. Without this, a single sync fires
+ # three or four ticks.
+ sleep 0.3
+ tick
+ done
+
+ # Falling out means inotifywait itself failed. Say so rather than exiting
+ # silently, which is indistinguishable from no mail arriving.
+ echo "mail-notify: inotify watch stopped" >&2
+ exit 1
+}
+
+# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch
+# loop. The bash equivalent of Python's __name__ == "__main__".
+[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@"
diff --git a/mail-overview/test-mail-notify.sh b/mail-overview/test-mail-notify.sh
new file mode 100755
index 0000000..1c3e01a
--- /dev/null
+++ b/mail-overview/test-mail-notify.sh
@@ -0,0 +1,148 @@
+#!/bin/bash
+#
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# The one runnable check for mail-notify.sh. No framework: it sources the
+# script as a library and asserts on the pure functions, the ones that take
+# text and print text. Nothing here calls notmuch, dunstify or inotify, which
+# is why it runs in milliseconds and needs no mail to arrive.
+#
+# Usage: ./test-mail-notify.sh (exit 0 = all passed)
+
+set -u
+
+MAIL_NOTIFY_LIB=1 . "$(dirname "$0")/mail-notify.sh"
+
+pass=0
+fail=0
+
+# Compares two strings and reports. Multi-line values are printed with their
+# newlines intact, because half the assertions here are about line structure.
+check() {
+ local name="$1" want="$2" got="$3"
+ if [[ "$want" == "$got" ]]; then
+ pass=$((pass + 1))
+ else
+ fail=$((fail + 1))
+ printf 'FAIL: %s\n want: %s\n got: %s\n' "$name" "$want" "$got"
+ fi
+}
+
+check "library guard does not run main" "loaded" "loaded"
+
+# A config with the two traps in it: a key containing dots, and a folder
+# value containing a bracket. Both are real shapes from qtmaildir.conf, with
+# placeholder names.
+read -r -d '' fixture <<'EOF'
+[general]
+theme = dark
+
+[account.simple]
+label = Simple
+color = #112233
+
+[account.provider-first.last]
+folder = [Gmail]/Bozze
+label = Dotted
+color = #445566
+
+[account.nolabel]
+color = #778899
+
+[ui]
+label = NotAnAccount
+EOF
+
+check "keys run to the bracket, not the first dot" \
+ "simple provider-first.last nolabel" \
+ "$(parse_accounts <<<"$fixture" | cut -f1 | tr '\n' ' ' | sed 's/ $//')"
+
+check "a bracket in a value does not end the section" \
+ "Dotted" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="provider-first.last"{print $2}')"
+
+check "a missing label falls back to the key" \
+ "nolabel" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="nolabel"{print $2}')"
+
+check "a non-account section is not an account" \
+ "" \
+ "$(parse_accounts <<<"$fixture" | awk -F'\t' '$1=="ui"{print $2}')"
+
+# The shape notmuch search --format=json actually returns, trimmed to the two
+# fields the body uses.
+rows_json='[
+ {"authors":"Alice Example","subject":"First subject"},
+ {"authors":"Bob Example","subject":"Second subject"},
+ {"authors":"Carol Example","subject":"Third subject"}
+]'
+
+check "a body lists author and subject per row" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject" \
+ "$(build_body "$rows_json" 3)"
+
+check "a batch bigger than the rows shown is elided" \
+ "• Alice Example — First subject
+
+• Bob Example — Second subject
+
+• Carol Example — Third subject
++7 more" \
+ "$(build_body "$rows_json" 10)"
+
+check "markup characters are escaped, not rendered" \
+ "• A &amp; B — &lt;script&gt;" \
+ "$(build_body '[{"authors":"A & B","subject":"<script>"}]' 1)"
+
+check "a missing subject says so rather than printing nothing" \
+ "• Alice Example — (no subject)" \
+ "$(build_body '[{"authors":"Alice Example","subject":null}]' 1)"
+
+check "malformed json yields an empty body rather than an error" \
+ "" \
+ "$(build_body 'not json at all' 1 2>/dev/null)"
+
+check "a newline in a subject stays on one row" \
+ "• Alice Example — line one line two" \
+ "$(build_body '[{"authors":"Alice Example","subject":"line one\nline two"}]' 1)"
+
+# A scratch state file, removed at exit. Never the real one: that is the
+# user's live notification position, and a test must not move it.
+tmpstate="$(mktemp)"
+trap 'rm -f "$tmpstate"' EXIT
+STATE="$tmpstate"
+
+rm -f "$tmpstate"
+check "a missing state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 500
+check "a revision written is a revision read back" \
+ "500" "$(read_prev_rev "uuid-a")"
+
+check "a different database UUID discards the revision" \
+ "" "$(read_prev_rev "uuid-b")"
+
+printf 'uuid-a garbage\n' > "$tmpstate"
+check "an unparseable state file reports no previous revision" \
+ "" "$(read_prev_rev "uuid-a")"
+
+write_state "uuid-a" 600
+check "a rewrite replaces rather than appends" \
+ "1" "$(wc -l < "$tmpstate")"
+
+printf '\n%d passed, %d failed\n' "$pass" "$fail"
+[[ "$fail" -eq 0 ]]