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
|
# mail-overview design
Date: 2026-09-12
A fourth quickshell component: a drawer giving an overview of the notmuch
indexed Maildir, with per account unread counts and the three newest unread
threads per account, opened from a single waybar icon that carries the total.
## Why
The current waybar setup runs three `custom/mail` modules, each polling the
Gmail API through a python script with its own credentials file, each showing
one account's count, each opening Thunderbird on click. That misses two of the
five accounts, needs network access and stored credentials to report a number
that is already in the local notmuch index, and opens a client that is not the
one in use.
Everything needed is local: notmuch tags every message with
`account-<key>`, and `qtmaildir.conf` already lists the accounts with display
labels and colours.
## Scope
In:
- one waybar module, icon plus the total unread across all accounts, updated
on notmuch commit rather than on a timer
- a drawer dropping from under the waybar, per account rows with unread counts
and the three newest unread threads
- buttons to launch qtmaildir and to run the sync script
Out, and why:
- a notification daemon. Unrelated and larger; new mail already shows as the
icon count changing.
- thread level actions (open this thread, mark read, archive). Blocked, not
deferred: qtmaildir takes no command line arguments, so nothing can tell it
which thread to open, and tagging from the panel would write the database
behind a possibly running client. The panel is read only.
- per account open. Same blocker. `startup_account` in the config is a static
setting, not a flag.
- deduplicating `Theme.qml`. A pre existing loose end across the other three
components; fixing it touches all of them and is its own change. This
component carries the same copy.
## Architecture
Two independent readers of one source of truth, the notmuch database. No
shared state, no daemon, no count cache.
Maildir --mbsync--> notmuch database (xapian)
|
+---------------------+--------------------+
| |
waybar script (continuous mode) qs mail-overview panel
inotifywait loop on the xapian dir notmuch count + search on open
prints one JSON line per commit parses qtmaildir.conf
click -> qs ipc call mail toggle
The database is written by the sync script on a cron schedule and by qtmaildir
when mail is read, so watching it covers both directions: the count rises on
new mail and falls as mail is read, with no polling in either case.
### Files
| File | Role |
| --- | --- |
| `mail-overview/shell.qml` | keepalive window, IpcHandler, the drawer |
| `mail-overview/Accounts.qml` | parses the config, runs notmuch, exposes the model |
| `mail-overview/Theme.qml` | the existing copy, unchanged |
| `mail-overview/waybar-mail.sh` | continuous mode watcher script |
| `mail-overview/README.md` | component notes |
## Accounts come from qtmaildir.conf
The account list is never hardcoded. `qtmaildir.conf` has one
`[account.<key>]` section per account, and `<key>` is exactly the suffix of
the notmuch tag `account-<key>`. Each section carries `label` (short display
name) and `color`. Sections are taken in file order, which is the display
order. Adding an account to qtmaildir makes it appear in the panel.
Parsing: read the file with `FileView` and `watchChanges: true`, so editing
the config updates the panel with no restart. Parse with an `exec` loop, not
`String.matchAll`: QML's JS engine has no `matchAll` and throws, which inside
a `try` looks like a parser quietly returning nothing.
Missing `label` falls back to the key. Missing `color` falls back to the Theme
foreground.
## Unread means inbox unread
Every count in both the icon and the panel is
`tag:unread and tag:inbox`, per account with
`and tag:account-<key>` appended.
Plain `tag:unread` also counts archived but unread mail and mailing list
traffic that was never in the inbox, which for one account is 41 against 32.
The inbox scoped number is the one that means "new mail worth looking at".
### Tags, not paths
qtmaildir scopes a single account with a path glob,
`path:"<maildir>/**" and tag:unread`. This design uses the `account-<key>` tag
instead, and the difference is not cosmetic.
notmuch deduplicates by message id, so one message that arrived at two of the
configured addresses is a single message with two file paths. A path glob
matches it under both accounts, so the per account counts sum to more than the
total: measured here, five accounts summed to 102 against a global total of
101. The `account-*` tag is a property of the message, so it is singular, and
the per account counts always sum to the total.
The waybar icon shows one global total and the panel header shows the same
number, so rows that sum to something else would read as a bug. The cost is
that a cross posted message appears under only one account, whichever the
post-new hook attributed it to. That is the right trade for an overview whose
headline figure is a single number.
Note also that the qtmaildir query has no inbox term: the path glob restricts
to the account but not to the inbox, so it includes archived unread mail unless
qtmaildir adds its own scope. The queries here are always inbox scoped.
## The waybar module
Continuous mode: the script does not exit, it prints one JSON line per
update, and waybar redraws on each line. The module therefore has no
`interval`, and there is no separate watcher process to supervise. Waybar owns
the process lifetime.
#!/bin/bash
db="$(notmuch config get database.path)/xapian"
emit() {
n=$(notmuch count 'tag:unread and tag:inbox')
...
}
emit
while inotifywait -qq -e close_write,moved_to "$db"; do
sleep 0.3
emit
done
Three things in that loop are deliberate:
- the watch is on the **directory**, not on named files. Xapian replaces files
on commit (unlink and rename), which breaks a watch held on a file.
- `close_write,moved_to` are the events a commit produces. `iamglass` is
rewritten every time.
- the short sleep coalesces the several file writes of one commit into one
emitted line.
Output is JSON so the module can carry a `class`: `unread` when the count is
above zero, `empty` at zero so the stylesheet can dim it. A failing
`notmuch count` emits an error class rather than exiting the loop, so a
transient database lock does not silently kill the module.
The count is a single accumulated total across all accounts. The script does
not parse the config: a total is a total, and that keeps it to one notmuch
call.
Left click toggles the drawer over IPC. Right click runs the sync script.
## The drawer
A `PanelWindow` anchored top and right on the primary monitor, with
`exclusionMode` set to respect other surfaces' exclusive zones while claiming
none of its own. Waybar sets an exclusive zone, so the compositor places the
drawer below it without this component knowing waybar's height. Anchoring
right puts it under the icon, which sits in `modules-right`, with no
coordinate arithmetic to go stale when the module list changes.
Width is fixed at roughly 460px, height follows the content.
A distinct layershell namespace, `qs-mail`, so a Hyprland layer rule can blur
it. Without the rule it renders flat translucent.
Escape closes it. The focus is set on the inner content item, not on the
window: key events reach a focused item, and `Keys.onEscapePressed` on a
`PanelWindow` never fires. Clicking outside also closes it.
The component holds itself open with the usual 1x1 transparent keepalive
`PanelWindow` with an empty mask. Without it a config whose only window is
hidden exits silently, and the symptom is a keybind that appears to do
nothing.
### Layout
+------------------------------------------+
| Mail 101 unread |
+------------------------------------------+
| * Account A 36 |
| Some Sender Today 06:18 |
| [a-list] a subject line that is... |
| ...two more... |
+------------------------------------------+
| * Account B 19 |
+------------------------------------------+
| * Account C 32 |
| * Account D 4 |
| * Account E 1 |
+------------------------------------------+
| Open qtmaildir Sync now |
+------------------------------------------+
The dot carries the account's own `color` from the config. Labels and counts
use Theme colours: the per account colours are identity from configuration,
not a palette, and no component defines a palette.
Each account shows its three newest unread threads as author, relative date
and subject, author and subject elided. These are read only for the reason in
Scope. An account with no unread mail keeps its row with a dimmed count and no
thread rows.
"Open qtmaildir" launches the client. "Sync now" runs the sync script, which
is already lock protected against a concurrent cron run, and is disabled while
running; the watcher picks up whatever the sync commits.
### Data
On open, per account:
notmuch search --format=json --limit=3 --sort=newest-first \
'tag:unread and tag:inbox and tag:account-<key>'
notmuch count 'tag:unread and tag:inbox and tag:account-<key>'
Two calls because a search limited to three rows cannot report the total. Five
accounts is ten short calls against a local index.
A `Process` that is already running ignores an assignment of `running = true`,
so any `Process` reused for a sequence of commands is set to `running = false`
immediately before each start.
A refresh timer runs while the drawer is visible and is stopped when it is
hidden. The drawer is open for seconds at a time, so it does not need its own
inotify watch on top of the timer.
## Error handling
- notmuch missing, or the database locked: the count shows a dash, never a
zero. A zero that is actually a failure reads as "no new mail", which is the
same class of mistake as reporting a libvirt host side figure as guest
memory.
- config unreadable, or no `[account.*]` sections: the panel says so in one
line. The waybar total still works, since the script does not read the
config.
- `notmuch search` exits non zero: keep the previous counts rather than
blanking the panel.
- the waybar script's `notmuch count` fails: emit an error class and keep
looping.
## Verification
The watcher is the one piece with non trivial logic, and it is runnable on its
own: start `waybar-mail.sh` in a terminal, change a tag with
`notmuch tag +unread -- id:<some id>` in another, and confirm a new line
prints within about a second. That check fails if the event set, the directory
watch or the debounce is wrong.
For the panel, start `qs -p mail-overview` so the harness owns the process and
confirm from the log. A detached `qs` does not survive an agent's tool call and
a later `pgrep` reports it dead regardless of whether the config is sound. The
process is named `qs`, so `pkill -x qs` and `pgrep -cx qs`; never `-f`, which
matches the caller's own command line.
Visual judgement is the user's. No screenshots of a transient drawer.
## Live configuration, not in this repo
Required for the component to work, but these files are the live Hyprland and
waybar configuration, which carries absolute home paths and is not committed
here.
1. The waybar mail module file: drop the three Gmail API entries, add one
`custom/mail` with the script, the JSON return type, and the click actions.
In the bar config, replace the three module names in `modules-right` with
the one. Optionally a dim rule for the empty class in the stylesheet.
2. A Hyprland layer rule for the `qs-mail` namespace in the decorations
section, matching what the other components use, then `hyprctl reload`.
3. An autostart line for the component alongside the existing three, so the
IPC target exists when waybar clicks it, then `hyprctl reload`.
Dead once the modules are replaced: the polybar gmail module directory with
its python script and three credentials files. Nothing else references it.
|