aboutsummaryrefslogtreecommitdiffstats
path: root/docs/superpowers/specs/2026-09-13-mail-arrival-notifications-design.md
blob: 1423fe9896dd2ea8ab33c5726a044f73b6b4e5db (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
# mail-overview: notify on mail arrival

New mail should announce itself. Today nothing does: `mailsync.sh` syncs and
writes a status file, `waybar-mail.sh` updates a number in the bar, and the
drawer shows detail only when opened. A message that lands while the user is
looking at something else is silent.

This adds one notification per account per arriving batch, from a new script
in this component.

## What it is not

It is not the quickshell notification daemon that would replace dunst. That
remains deferred and is independent of this: notifications here are sent over
the freedesktop DBus spec, so they work with dunst today and keep working
unchanged if the daemon is ever swapped in. Nothing here should wait for it.

## Shape

`mail-overview/mail-notify.sh`, bash, sibling to `waybar-mail.sh`, GPLv2
header like every other source file here. Started from `autostart.lua`, runs
for the whole session.

    resolve db path from `notmuch config get database.path`
    guard: db dir missing -> complain on stderr, exit 1
    seed state silently (no startup notification)
    while inotifywait -qq -e close_write,moved_to "$db"; do
        sleep 0.3
        notify_new
    done
    complain, exit 1

The watch idiom is copied from `waybar-mail.sh` rather than shared. It is
about six lines, and two copies of six lines beat an abstraction spanning a
bar module and a notifier, which have different owners and different
lifetimes.

**Why a separate process rather than extending `waybar-mail.sh`.** That
script already has the arrival edge, and reusing it would be the shortest
diff. It was rejected because waybar owns that process: a waybar restart or a
`hyprctl reload` would stop mail notifications with nothing reporting it. A
notifier that silently stops is worse than one that costs a second inotify
watch. Running it inside quickshell was also rejected: `FileView` watches
files, not directories, and a watch on a file inside the Xapian directory
dies on commit (the trap already recorded in AGENTS.md), so it would need a
`Process` running `inotifywait` anyway.

## What counts as new

A notmuch commit is not the same as new mail. Reading a message in qtmaildir
drops its `unread` tag and commits; so does tagging. Arrival is detected with
notmuch's own revision counter.

    notmuch count --lastmod 'tag:unread and tag:inbox'

prints three tab-separated fields: count, database UUID, revision. The
revision is field 3. Verified on notmuch 0.39.

Per tick, if the revision has not advanced, there is nothing to do. Otherwise,
per account:

    notmuch count "tag:unread and tag:inbox and tag:account-<key> and lastmod:<prev+1>..<cur>"
    notmuch search --format=json --limit=3 --sort=newest-first "<same query>"

Two calls, the same count-plus-preview pair `Accounts.qml` already makes, and
only for accounts whose query is non-empty. The count gives the true N for
"+N more"; the search gives the rows. The lower bound is exclusive (`prev+1`)
so the revision just written, which is inclusive at the top end, is not
re-matched on the next tick.

`search --format=json` returns `authors` and `subject` directly, which is all
the body needs.

**Alternatives rejected.** A per-account count delta is simpler but cannot
name senders, which was the point of the feature. A timestamp watermark using
`date:@<ts>..` is quietly broken: `date:` is the message's Date header, so
backdated mail never notifies and future-dated mail notifies forever.

## State

`~/.local/state/mail-notify.lastmod`, alongside `mail-watcher.heartbeat` and
`mailsync.log`. Written by atomic replace (tmpfile then rename), the same
idiom the heartbeat uses.

It stores the database UUID as well as the revision. notmuch revisions are
only comparable within one database: a rebuilt database restarts the counter,
and a stored revision from the old one would then be meaningless. A UUID
mismatch is treated exactly like a missing file.

**Missing, unparseable, or UUID-mismatched state seeds silently:** record the
current revision, notify nothing. Without this, a first run has no floor,
`lastmod:0..` matches every unread inbox message ever, and startup is a wall
of popups. Measured here: 101 unread messages across five accounts.

The same applies to a restart mid-session. Mail that arrived while the script
was down is never notified. This is the right trade: the waybar count is still
correct and the drawer still shows the mail, so nothing is lost except a
popup that would have been stale anyway.

The new revision is written **after** every account has been processed. A
single account whose count does not validate is skipped for that tick, and the
revision still advances: holding it back would make every later tick re-notify
the successful accounts' whole range. Only a failure to write the state file
itself is non-fatal and leaves the old revision, so the next tick re-notifies
rather than dropping mail.

## Accounts

Parsed from `qtmaildir.conf`, the same file the drawer parses, so adding an
account in qtmaildir makes it notify with no edit here.

Two details carry over from `Accounts.qml` and are not optional:

- **The key runs to the closing bracket, not to the first dot.** Real keys
  contain dots: a section like `[account.provider-first.last]` maps to the
  notmuch tag `account-provider-first.last`. Splitting on the first dot yields
  a tag that matches nothing, and an account that never notifies.
- **Walk lines; never match "everything up to the next `[`".** Several
  accounts have folders named like `[Gmail]/Bozze`, which ends a section body
  before its `label` and makes the account display its raw key.

The `label` is what the notification summary shows.

## The notification

One per account with new mail:

    dunstify -a "New Mail" -u normal -t 10000 -b \
             -i "$MAIL_ICON" \
             -h string:x-dunst-stack-tag:mail-<key> \
             -A default,open \
             "<label> (<count>)" "<body>"

The heading is split to match the running dunst `format`, which renders `%a`
(the app name) bold on the top line and `%s` (the summary) italic below it:
the app name is `New Mail`, the summary is `<label> (<count>)`.

Body: up to 3 bullet rows of author and subject, each on its own line with a
blank line between, then `+N more` when N > 3. Three matches the drawer's own
`--limit=3`, and bounds the height so a 20-message mailing list burst does not
become a wall.

`MAIL_ICON` is an absolute path, not an icon name. The running dunst resolves
a themed icon name only through the `icon_path` in `dunstrc`, which holds no
mail icon, so a name renders nothing; an absolute path under the active theme
resolves, as the machine's other notifiers already do. It points at
`${XDG_DATA_HOME:-$HOME/.local/share}/icons/MB-Blueberry-Suru-GLOW/actions/24/mail-unread-multiple.svg`.

Because the notification carries `-A`, dunst prepends an `(A)` action
indicator when `show_indicators` is on, so `dunstrc` sets
`show_indicators = no`.

Normal urgency and an explicit 10s timeout. Deliberately not `-u critical`:
on most dunst configurations critical notifications never expire, which
would leave mail popups stuck on screen.

**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.

**Click opens qtmaildir.** `-A default,open` plus `-b` makes dunstify block
until the notification is dismissed or actioned and print the action key, so
each notification is launched in a backgrounded subshell that waits and runs
`~/bin/qtmaildir` on `default`. Without backgrounding, the loop would stall
for the full timeout on every account. With the running dunst's
`mouse_middle_click = do_action`, it is a middle click that invokes it.

## Failure handling

The existing rule for this component applies unchanged: **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; a query Xapian merely
misparses returns a plausible wrong number and exits 0.

So every count is validated as `^[0-9]+$`. An account whose count does not
validate is skipped for that tick with no notification. The defence against
the second failure mode is that the queries are fixed strings with only the
account key and the two revisions interpolated, never built from anything
else.

Falling out of the `inotifywait` loop means the watch itself died. The script
says so on stderr and exits non-zero rather than exiting silently, which would
look indistinguishable from no mail arriving.

## Verification

The script is runnable standalone, which is what makes this testable without
waiting for real mail: write a `prev` revision a few hundred revisions behind
current into the state file, run one tick, and confirm the per-account
notifications appear with the right counts, that no storm occurs, and that the
state file advances to the current revision.

That single check is enough. It exercises the parse, the query, the body
construction and the state write together, and it fails if any of them break.