diff options
Diffstat (limited to 'mail-overview/mail-notify.sh')
| -rwxr-xr-x | mail-overview/mail-notify.sh | 295 |
1 files changed, 0 insertions, 295 deletions
diff --git a/mail-overview/mail-notify.sh b/mail-overview/mail-notify.sh deleted file mode 100755 index b132775..0000000 --- a/mail-overview/mail-notify.sh +++ /dev/null @@ -1,295 +0,0 @@ -#!/bin/bash -# -# Copyright (C) 2026 Danilo M. <danix@danix.xyz> -# -# This program is free software; you can redistribute it and/or modify -# it under the terms of the GNU General Public License version 2 as -# published by the Free Software Foundation. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# Notifies when mail arrives: one notification per account per batch, naming -# the newest senders and subjects. -# -# Watches the notmuch Xapian directory and, on each commit, asks notmuch what -# changed since the revision it last saw. A commit is NOT the same as new -# mail, since reading and tagging also commit, which is why the revision -# counter does the work rather than a count delta. -# -# This is its own process rather than part of waybar-mail.sh, which already -# has the same arrival edge: waybar owns that process, so a bar restart would -# stop notifications with nothing reporting it. -# -# Usage: -# mail-notify.sh watch forever (what autostart runs) -# mail-notify.sh --once process one tick and exit (for manual verification, one tick) - -set -u - -STATE="${MAIL_NOTIFY_STATE:-$HOME/.local/state/mail-notify.lastmod}" -CONFIG="${MAIL_NOTIFY_CONFIG:-$HOME/.config/qtmaildir/qtmaildir.conf}" -SCOPE='tag:unread and tag:inbox' - -# How many threads a notification body lists before eliding into "+N more". -# Three matches the drawer's own --limit=3. -ROWS=3 - -# dunst here resolves a themed icon NAME only through its icon_path, which -# holds no mail icon, so a name renders nothing (dunst stores an empty -# icon_path for it). Pass an absolute path, as this machine's other -# notifiers do. This is the icon the user picked; ${XDG_DATA_HOME} keeps a -# home path out of the committed file. -MAIL_ICON="${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg" - -# Accounts in file order, one "key<TAB>label" line each, read from stdin. -# -# Two things here are load-bearing, and both have already broken this -# component once: -# -# The key runs to the closing bracket, NOT to the first dot. Real keys -# contain dots, so splitting on the first one yields a notmuch tag matching -# nothing and an account that silently never notifies. -# -# And this walks lines rather than matching a section body as "everything up -# to the next [". Accounts have folders named like [Gmail]/Bozze, which ends -# the body before its label and makes the account display its raw key. -parse_accounts() { - local line key label - key="" - label="" - - while IFS= read -r line || [[ -n "$line" ]]; do - if [[ "$line" =~ ^\[account\.([^]]+)\] ]]; then - [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" - key="${BASH_REMATCH[1]}" - label="" - continue - fi - # Any other section ends the current account. - if [[ "$line" =~ ^\[ ]]; then - [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" - key="" - label="" - continue - fi - [[ -n "$key" ]] || continue - if [[ "$line" =~ ^[[:space:]]*label[[:space:]]*=[[:space:]]*(.*)$ ]]; then - label="${BASH_REMATCH[1]}" - # Trailing whitespace only; a label may contain spaces. - label="${label%"${label##*[![:space:]]}"}" - fi - done - - [[ -n "$key" ]] && printf '%s\t%s\n' "$key" "${label:-$key}" - return 0 -} - -# Renders notmuch search JSON into notification body text. -# $1 the JSON array from `notmuch search --format=json` -# $2 the true total for this batch, which may exceed the rows present -# -# dunst has body-markup in its capabilities, so a subject containing < or & -# would be parsed as markup and could vanish from the notification. Subjects -# are attacker-controlled text arriving from the internet, so the three XML -# characters are escaped here. This is the one place in this script where -# untrusted text reaches a renderer. -# -# Malformed JSON prints nothing and succeeds. A notification with no body is -# still worth sending: the summary already carries the account and the count. -build_body() { - local json="$1" total="$2" shown rowtext body - - rowtext="$(printf '%s' "$json" | jq -r ' - .[] | "• " + ((.authors // "(unknown)") + " — " + (.subject // "(no subject)")) - | gsub("[\r\n]+"; " ") - | gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">") - ' 2>/dev/null)" || return 0 - [[ -n "$rowtext" ]] || return 0 - - shown="$(printf '%s\n' "$rowtext" | wc -l)" - body="$(printf '%s\n' "$rowtext" | awk 'NR>1{print ""} 1')" - - printf '%s' "$body" - if [[ "$total" -gt "$shown" ]]; then - printf '\n+%d more' "$((total - shown))" - fi - printf '\n' -} - -# The last revision this script notified up to, or empty when there is none -# to trust. Empty means "seed silently": record where we are now and notify -# nothing. -# -# The stored UUID is checked because notmuch revisions are only comparable -# within one database. A rebuilt database restarts the counter, so an old -# revision would be meaningless, and treating it as a floor would either -# notify nothing forever or notify everything at once. -read_prev_rev() { - local want_uuid="$1" got_uuid rev - - [[ -f "$STATE" ]] || return 0 - read -r got_uuid rev < "$STATE" 2>/dev/null || return 0 - - [[ "$got_uuid" == "$want_uuid" ]] || return 0 - [[ "$rev" =~ ^[0-9]+$ ]] || return 0 - - printf '%s' "$rev" -} - -# Written by atomic replace, the same idiom mail-watcher uses for its -# heartbeat: a reader must never see a half-written file, and mv within a -# directory is atomic where a redirect into the final path is not. -# -# Failure to write is deliberately not fatal. The notifications have already -# been sent; taking the watcher down over a failure to record that would turn -# a bookkeeping problem into a no-mail-notifications problem. -write_state() { - local uuid="$1" rev="$2" tmp - - mkdir -p "$(dirname "$STATE")" 2>/dev/null || return 0 - tmp="$(mktemp "${STATE}.XXXXXX")" || return 0 - printf '%s %s\n' "$uuid" "$rev" > "$tmp" || { rm -f "$tmp"; return 0; } - mv -f "$tmp" "$STATE" 2>/dev/null || rm -f "$tmp" - return 0 -} - -# Sends one notification for one account. -# -# dunstify rather than notify-send because actions need it. A stack tag per -# account means a second batch for the same account replaces the first rather -# than stacking, which is what "one notification per account" has to mean when -# mail keeps arriving. -# -# Normal urgency and an explicit 10s timeout, deliberately not -u critical: -# on most dunst configurations critical notifications never expire, which -# would leave mail popups on screen until clicked. -# -# The click cannot open the account it belongs to. qtmaildir accepts no -# command line arguments and startup_account is a static config setting, not -# a flag, which is the same limitation the drawer's thread rows already have. -notify_account() { - local label="$1" key="$2" count="$3" body="$4" - - # -a carries "New Mail" because the user's dunst format renders %a as the - # bold heading line, with %s italic below it. - if ! command -v dunstify >/dev/null 2>&1; then - # No actions available, but a notification without a click is still - # worth having. - notify-send -a "New Mail" -u normal -t 10000 -i "$MAIL_ICON" \ - "$label ($count)" "$body" - return 0 - fi - - # Backgrounded because -b blocks until the notification is dismissed or - # clicked. Without this the loop would stall for the full timeout on - # every account, and a five-account batch would take most of a minute. - ( - if [[ "$(dunstify -a "New Mail" -i "$MAIL_ICON" -u normal -t 10000 -b \ - -h "string:x-dunst-stack-tag:mail-$key" \ - -A "default,open" \ - "$label ($count)" "$body")" == "default" ]]; then - "$HOME/bin/qtmaildir" & - fi - ) >/dev/null 2>&1 & -} - -# One pass: what has arrived since the revision we last saw. -tick() { - local lastmod uuid cur prev - - # Three tab-separated fields: count, database UUID, revision. Verified on - # notmuch 0.39. - lastmod="$(notmuch count --lastmod "$SCOPE" 2>/dev/null)" || return 0 - uuid="$(printf '%s' "$lastmod" | cut -f2)" - cur="$(printf '%s' "$lastmod" | cut -f3)" - - # The output is the test, not the exit status. notmuch fails two ways and - # only one is detectable: a rejected query prints nothing and exits 1, - # while a query Xapian merely misparses returns a plausible wrong number - # and exits 0. The defence against the second is that SCOPE is a fixed - # string and is never built from anything. - [[ "$cur" =~ ^[0-9]+$ ]] || return 0 - [[ -n "$uuid" ]] || return 0 - - prev="$(read_prev_rev "$uuid")" - - # No trustworthy floor: record where we are and say nothing. This is the - # first run, a rebuilt database, or a corrupt state file. - if [[ -z "$prev" ]]; then - write_state "$uuid" "$cur" - return 0 - fi - - # Nothing committed since last time, or the counter went backwards. - if [[ "$cur" -le "$prev" ]]; then - return 0 - fi - - local key label query count rows - while IFS=$'\t' read -r key label; do - [[ -n "$key" ]] || continue - - query="$SCOPE and tag:account-$key and lastmod:$((prev + 1))..$cur" - - count="$(notmuch count "$query" 2>/dev/null)" - # Same validation, same reason: an empty string must not become a - # zero, and a failure here skips this account rather than the batch. - [[ "$count" =~ ^[0-9]+$ ]] || continue - [[ "$count" -gt 0 ]] || continue - - rows="$(notmuch search --format=json --limit="$ROWS" \ - --sort=newest-first "$query" 2>/dev/null)" || rows="[]" - - notify_account "$label" "$key" "$count" "$(build_body "$rows" "$count")" - done < <(parse_accounts < "$CONFIG") - - # Written only after every account is done. A single account whose count - # fails to validate is skipped above but does not hold the revision back; - # leaving it behind would re-notify every successful account's range on - # each later tick. Only a failed state write itself leaves prev unchanged. - write_state "$uuid" "$cur" -} - -main() { - local db - db="$(notmuch config get database.path 2>/dev/null)/xapian" - - if [[ ! -d "$db" ]]; then - echo "mail-notify: no notmuch database at $db" >&2 - exit 1 - fi - - if [[ ! -r "$CONFIG" ]]; then - echo "mail-notify: cannot read $CONFIG" >&2 - exit 1 - fi - - if [[ "${1:-}" == "--once" ]]; then - tick - return 0 - fi - - # Seed before watching, so a first run never notifies the backlog. - tick - - # The watch is on the xapian DIRECTORY, not a file inside it: a commit - # replaces files, and a watch held on a filename dies with the file. - while inotifywait -qq -e close_write,moved_to "$db" 2>/dev/null; do - # One commit touches several files. Without this, a single sync fires - # three or four ticks. - sleep 0.3 - tick - done - - # Falling out means inotifywait itself failed. Say so rather than exiting - # silently, which is indistinguishable from no mail arriving. - echo "mail-notify: inotify watch stopped" >&2 - exit 1 -} - -# Sourced by the test with MAIL_NOTIFY_LIB set, which must not start a watch -# loop. The bash equivalent of Python's __name__ == "__main__". -[[ -n "${MAIL_NOTIFY_LIB:-}" ]] || main "$@" |
