1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
|
#!/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 (what the test drives)
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
# 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
local body
body="$(printf '%s' "$json" | jq -r '
.[] | ((.authors // "(unknown)") + " — " + (.subject // "(no subject)"))
| gsub("&"; "&") | gsub("<"; "<") | gsub(">"; ">")
' 2>/dev/null)" || return 0
[[ -n "$body" ]] || return 0
shown="$(printf '%s\n' "$body" | wc -l)"
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
}
main() {
echo "not implemented"
}
# 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 "$@"
|