#!/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 "$@"