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
|
# mail-overview
Unread mail across every account, as one waybar number and a drawer behind it.
Clicking the icon opens the drawer; Escape or a click outside closes it.
┌──────────────────────────────────────────────────┐
│ Mail 33 unread │
├──────────────────────────────────────────────────┤
│ ● Account A 2 │
│ Some Sender Today 06:18 │
│ [a-list] a subject line that is elided... │
├──────────────────────────────────────────────────┤
│ ● Account B 0 │
│ ● Account C 31 │
│ ...three newest threads... │
│ ● Account D 0 │
│ ● Account E 0 │
├──────────────────────────────────────────────────┤
│ ● watcher ok · 25 folders │
├──────────────────────────────────────────────────┤
│ [Open qtmaildir] │
└──────────────────────────────────────────────────┘
## Running it
qs -p .
It is started from `autostart.lua` and reached over IPC, so the shell has to be
running for the waybar click to do anything:
qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle
Write that path out in full in the real config. Waybar's `exec` and `on-click`
have no shell to expand `~`, and neither do Hyprland's Lua strings.
## The accounts are not listed here
`qtmaildir.conf` already has one `[account.<key>]` section per account, and
`<key>` is exactly the suffix of the notmuch tag `account-<key>`, with a short
`label` and a `color` alongside. The panel parses that file, in file order, so
adding an account to qtmaildir makes it appear here with no edit to any QML.
The file is watched, so that happens without a restart.
The `color` is the account's own, from its config section. It marks the dot
beside each row and nothing else: per-account identity is not a palette, and
`Theme` still owns every other colour.
**Parsing walks lines rather than matching one regex over the file.** The
obvious pattern for a section body, everything up to the next `[`, is wrong
here: several accounts have folders named like `[Gmail]/Bozze`, so the body
ended at that bracket, before the `label` line, and three of five accounts
quietly fell back to displaying their raw key. Nothing errored, because falling
back is a legitimate path for an account with no label.
## Counting
Every count is `tag:unread and tag:inbox`. Plain `tag:unread` also counts
archived-but-unread mail and mailing list traffic that was never in the inbox,
which for one account here was the difference between 32 and 41; the
inbox-scoped number is the one that means new mail worth looking at.
**By the `account-*` tag, never by a `path:` glob.** qtmaildir itself scopes an
account with `path:"<maildir>/**"`, and copying that query would be wrong for
this panel. notmuch deduplicates by message id, so a message that arrived at
two of the configured addresses is one message with two file paths: a glob
counts it under both accounts, and the rows then sum to more than the total the
waybar icon shows. Measured here, five accounts summed to 102 against a global
total of 101. The tag is a property of the message, so it is singular and the
rows always sum to the header. The cost is that such a message appears under
only the account the `post-new` hook attributed it to, which is the right trade
for an overview whose headline figure is a single number.
## notmuch fails two ways, and only one is detectable
A query notmuch rejects prints nothing and exits 1:
notmuch count 'tag:unread and ('
A query Xapian merely misparses returns a plausible wrong number and exits 0:
notmuch count 'tag:unread and ((' # 41, exit 0
notmuch count 'tag:unread and tag:' # 3, exit 0
So the exit status is not the test, and neither is any check that a wrong
number could pass. Counts are validated as non-negative integers, which catches
the first case, where empty output would otherwise render as an empty inbox.
The second is not catchable; the defence against it is that the queries are
fixed strings and are never built from anything.
An unknown count shows a dash, never a zero, for the same reason the VM panel
refuses to substitute libvirt's host-side figures: a number that is actually a
failure reads as a fact.
## The waybar module
A `custom` module in **continuous mode**: the script never exits, prints one
JSON line per database commit, and waybar redraws on each line. There is no
`interval`, and no daemon to supervise, because waybar owns the process.
"custom/mail": {
"format": "<span font='18px'></span> {}",
"return-type": "json",
"exec": "~/Programming/GIT/quickshell/mail-overview/waybar-mail.sh",
"on-click": "qs -p ~/Programming/GIT/quickshell/mail-overview ipc call mail toggle",
"on-click-right": "~/bin/mailsync.sh",
"tooltip": false
}
The count drops the moment mail is read in qtmaildir and rises the moment
mbsync commits, because both write the database and the watcher sees either.
Two details in that loop are load-bearing. The watch is on the **xapian
directory**, not on a file inside it: a commit replaces files, and a watch held
on a filename dies with it. And a short sleep coalesces the several writes of
one commit, without which the module redraws three or four times per sync with
intermediate counts.
The old setup this replaces ran three copies of a python script polling the
Gmail API with stored credentials, covered three of the five accounts, and
opened Thunderbird. Everything it fetched over the network was already in the
local index.
## Notifications on arrival
`mail-notify.sh` sends one notification per account when mail lands, with the
newest three threads' sender and subject and a `+N more` line when the batch is
bigger. Each message is a bullet on its own line with a blank line between,
under a bold `New Mail` heading with the account and count as the second line.
Started from `autostart.lua`, it runs for the whole session.
/home/you/Programming/GIT/quickshell/mail-overview/mail-notify.sh
It watches the same xapian directory the waybar module does, for the same
reason and with the same 0.3s debounce. It is a **separate process rather
than part of `waybar-mail.sh`**, which already has that edge: waybar owns
that script's process, so a bar restart would stop notifications with nothing
reporting it.
**A commit is not the same as new mail.** Reading a message in qtmaildir drops
its `unread` tag and commits; so does tagging. Arrival is found with notmuch's
revision counter instead:
notmuch count --lastmod 'tag:unread and tag:inbox'
which prints count, database UUID and revision, tab separated. Each account is
then asked what it gained in `lastmod:<prev+1>..<cur>`, the lower bound one
revision after the stored one so it is exclusive. A count delta was
rejected because it cannot name senders, and a `date:` watermark because
`date:` is the message's own Date header: backdated mail would never notify
and future-dated mail would notify forever.
Position is kept in `~/.local/state/mail-notify.lastmod`, written by atomic
replace, holding the **UUID as well as the revision**. Revisions only compare
within one database, so a rebuild restarts the counter and a stored revision
from the old one means nothing.
**Missing, corrupt or mismatched state seeds silently**, recording the current
revision and notifying nothing. Without that floor, `lastmod:0..` matches every
unread message ever: 101 of them here, which is a wall of popups at every
login. The same applies to a restart, so mail that arrived while it was down is
never notified. The waybar count is still right and the drawer still shows it,
so nothing is lost but a stale popup.
The notification is sent with dunstify. The icon is passed as an **absolute
path** to the active icon theme's `mail-unread-multiple`, because this dunst
resolves an icon *name* only through the `icon_path` in `dunstrc`, and that
path holds no mail icon, so a name renders no icon at all. The click action
(`-A default,open`) makes dunst prepend an `(A)` action indicator when
`show_indicators` is on, so `dunstrc` sets `show_indicators = no`. A middle
click (dunst's `do_action`) opens qtmaildir, but not the account it belongs
to: `qtmaildir` takes no arguments and `startup_account` is a static setting
rather than a flag, the same limitation the thread rows have.
`test-mail-notify.sh` is the check. It sources the script as a library and
asserts on the functions that only move text around, so it needs no mail and no
notmuch:
./test-mail-notify.sh
## Geometry, and the one property that differs from the other panels
The window is a fullscreen overlay with a dimmed backdrop and the drawer itself
anchored to its top right, which is the idiom `vm-manager` and `appearance` both
use and what gives click-outside-to-close and a focusable item for Escape.
It differs from those two in one property: `exclusionMode` is `Normal`, not
`Ignore`. Waybar claims an exclusive zone at the top of this screen, so
respecting it places the overlay below the bar with no height constant here to
drift. Measured: waybar at `y=-540 h=42`, this overlay at `y=-498 h=1038`,
starting exactly where the bar ends. The backdrop therefore never dims waybar.
**Key events reach a focused item, not a window.** Setting
`WlrLayershell.keyboardFocus` is necessary but not sufficient, and
`Keys.onEscapePressed` on a `PanelWindow` never fires, so the focus sits on an
inner `Item`.
## Thread rows are read-only
Each account shows its three newest unread threads as author, relative date and
subject. Clicking one launches qtmaildir and nothing more, because `qtmaildir`
accepts no command line arguments: there is no way to tell it which thread to
open. `startup_account` in its config is a static setting, not a flag, so
"open on this account" is unavailable for the same reason. Marking read from
here was rejected rather than deferred: it would write the database behind a
possibly running client.
"Open qtmaildir" launches the client. There is no "Sync now" button: the
watcher triggers a sync the moment mail lands, the cron tick is the backstop,
and `on-click-right` on the waybar module runs `~/bin/mailsync.sh` for a manual
pull.
## The watcher status dot
Below the accounts, above the button, a coloured dot reports whether
`mail-watcher` is alive and sane, read from its heartbeat at
`~/.local/state/mail-watcher.heartbeat`:
- **green** (`watcher ok · N folders`) heartbeat fresh, no dead threads.
- **yellow** (`N folder(s) dead, check the log`) heartbeat fresh, but a folder
gave up permanently.
- **red** (`watcher not running`) heartbeat missing, unparseable, or older than
300s.
Backoff never turns the dot: it is normal recovery from a dropped IDLE
connection, and the watcher's own health check treats it as healthy. The
staleness rule is the same one `mail-watcher` uses (`heartbeat_is_healthy`),
reimplemented here in a few lines rather than shelling out to
`mail-watcher.py --status` on every open.
The heartbeat is read once per open. The drawer is a `LazyLoader`, so closing
and reopening rebuilds the `FileView` and reads the file current. A file watch
is deliberately not used: the heartbeat is written by atomic replace (tmpfile
then rename), so an inotify watch held on the old inode dies with it, which is
the same trap as watching a file inside the Xapian directory. The cost is that
a drawer left open does not update until reopened, which for 60s heartbeat data
is not worth a timer.
## Theme and blur
`Theme.qml` is the shared one: the palette comes from
`~/.cache/wal/udt-palette.qml` and is watched. It is a fourth identical copy,
which is a known loose end, not a palette to grow.
Frosting is Hyprland's, matched on this window's namespace:
hl.layer_rule({
name = "blur-mail",
match = { namespace = "^(quickshell-mail)$" },
blur = true,
xray = false,
ignore_alpha = 0.1,
})
Without the rule it still works, rendering flat translucent.
|