diff options
Diffstat (limited to 'mail-overview')
| -rw-r--r-- | mail-overview/Accounts.qml | 24 | ||||
| -rw-r--r-- | mail-overview/MailPanel.qml | 78 | ||||
| -rw-r--r-- | mail-overview/README.md | 93 | ||||
| -rwxr-xr-x | mail-overview/mail-notify.sh | 295 | ||||
| -rwxr-xr-x | mail-overview/test-mail-notify.sh | 148 |
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("&"; "&") | 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/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 & 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 ]] |
