From d513418a2e8854bfe8702577cda6db2cbf0ce292 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Mon, 14 Sep 2026 12:27:41 +0200 Subject: feat(desktop): move mail-overview in as the mail module Accounts.qml is unchanged and MailPanel's body becomes the page. The three scripts move with it, which changes the absolute paths in autostart and in the waybar module; both are outside this repo and listed in the plan's final task. Button.qml, byte-identical here and in vm-manager, lands at the shell root as the single copy. --- desktop/modules/mail/mail-notify.sh | 295 ++++++++++++++++++++++++++++++++++++ 1 file changed, 295 insertions(+) create mode 100755 desktop/modules/mail/mail-notify.sh (limited to 'desktop/modules/mail/mail-notify.sh') diff --git a/desktop/modules/mail/mail-notify.sh b/desktop/modules/mail/mail-notify.sh new file mode 100755 index 0000000..b132775 --- /dev/null +++ b/desktop/modules/mail/mail-notify.sh @@ -0,0 +1,295 @@ +#!/bin/bash +# +# Copyright (C) 2026 Danilo M. +# +# 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 "keylabel" 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 "$@" -- cgit v1.2.3