aboutsummaryrefslogtreecommitdiffstats
path: root/mail-overview/mail-notify.sh
blob: e6c986869c1451f1c980cf4a1643a24a6dba9371 (plain)
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
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
#!/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

# 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("&"; "&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..$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, so a failure part way through
    # leaves prev unchanged and the next tick retries rather than dropping a
    # batch silently.
    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 "$@"