aboutsummaryrefslogtreecommitdiffstats
path: root/mail-overview/mail-notify.sh
diff options
context:
space:
mode:
Diffstat (limited to 'mail-overview/mail-notify.sh')
-rwxr-xr-xmail-overview/mail-notify.sh295
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("&"; "&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 "$@"