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
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
|
# Changelog
All notable changes to this project are documented here.
The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
While the version is below 1.0.0, the configuration file format and the
keybinding action names may change in a minor release. 1.0.0 will mark the
point at which they are stable.
## [Unreleased]
## [0.8.0] - 2026-08-04
Selecting more than one thread stops being a secret, and the window notices
the syncs it did not start.
### Added
- **Select all threads**, on **Ctrl+A** or Edit > Select all threads. Selecting
several threads always worked with Ctrl+click and Shift+click, but nothing in
the interface said so, and every tag action was reachable only from the
keyboard. Rebindable as `select_all` like any other action.
- **A right-click menu on the thread list**, holding archive, delete, spam,
mark read/unread, flag, edit tags and select all. It is built from the same
actions as the menu bar, so a rebinding in `[keys]` shows the new shortcut
here too. Right-clicking inside a multi-thread selection keeps that
selection rather than narrowing it to the row under the pointer.
- **A selection count in the status bar** while a selection is being built, and
a note in Help > Keyboard shortcuts describing Ctrl+click and Shift+click.
Mouse gestures belong to the view rather than to any action, so they cannot
appear in the generated shortcut table.
- **Awareness of syncs started elsewhere.** A cron sync every ten minutes used
to come and go unnoticed. The status bar now reports a background sync while
it runs and when it finishes, and suggests refreshing. It deliberately does
not refresh on its own: re-running the query clears the undo stack, the
selection and the message pane, which is right for a query you typed and
hostile for one a timer fired.
- **`assets/mailsync.sh`**, the reference sync command, moved here from the
companion `mailctl` project. It never belonged there: `mailctl` does not call
it, while qtmaildir runs it as a subprocess and depends on how it behaves.
Symlink it into `~/bin` rather than copying, so one script serves both cron
and the application.
### Changed
- **Selecting several threads no longer opens them.** The message pane blanks
for a multi-thread selection instead of loading each row as the selection
passes over it, and no thread selected that way is marked read. Selecting is
not reading, and a selection gesture must never change what is in the
Maildir. Narrowing back to a single thread opens it as before.
### Fixed
- **The sync log pane stayed empty**, listed as a known limitation since 0.1.0.
The reference `mailsync.sh` redirected all its output to a log file, so the
subprocess printed nothing for the pane to show. It now writes to both.
- **A failed sync reported success.** That script ended in an unconditional
`exit 0`, so qtmaildir could not tell a clean sync from a broken one: it
cleared the unsynced-changes count either way, and would have quit on a
sync-on-exit that had not synced anything. It now exits with the real status.
- **A sync you started reported itself as a background one**, replacing its own
result a moment after it finished. Whether a sync was local was decided when
its lock was released, by which time the process had already exited and the
answer was always "not ours".
## [0.7.0] - 2026-08-04
Tagging stops being limited to the five tags someone chose in advance, and the
application admits when your work has not reached the mail store yet.
### Added
- **An Edit tags dialog**, on **Ctrl+T** or Message > Edit tags. Type tags to
add or remove, separated by commas, or clear a checkbox to drop a tag already
on the selection without retyping it. Until now archive, delete, spam, flag
and toggle-unread were the only tags reachable from the UI, and applying any
other one meant leaving for a terminal.
- Both fields **complete against every tag in the database**, matching on
substrings so `amazon` finds `shopping/amazon`. Completion is a guard against
typing `shoppping` beside `shopping`, not a restriction: a tag that does not
exist yet is exactly what the dialog is for.
- With several threads selected, a tag on only some of them shows a partially
checked box saying how many. **Leaving it alone changes nothing.** Check it to
apply to all, clear it to remove from all.
- Tag names are refused if empty, if they start with `-` (notmuch reads that as
"remove this tag", so such a tag is a trap), or if they contain spaces or
unprintable characters. Nothing is applied until the whole set is valid, since
a half-applied change leaves you unable to tell which half landed.
- **The status bar counts tag changes a sync has not carried over**, and clears
the count when one succeeds. A failed sync leaves it standing.
- **Quitting with changes outstanding asks what to do**, via the new
`[general] sync_on_exit`: `ask` (the default) offers to sync, quit anyway or
stay; `always` syncs without asking; `never` quits silently. A sync started at
exit holds the window open until it finishes rather than being killed
mid-run, and one that fails does not quit.
### Fixed
- In the tag fields, only the first tag completed. `QLineEdit::setCompleter`
matches against the widget's entire text, so once a field read `unread, fl`
that whole string was matched against the tag names and nothing was offered
again. The same defect the query bar hit in 0.5.0, in a second place.
### Notes
The unsynced count is a lower bound rather than a guarantee: an external
`notmuch new` from your own cron can carry changes over without the application
noticing.
The exit prompt is not a destructive-action confirmation of the kind this
project avoids. Those cover tag mutations, which keep undo instead of a dialog.
This asks about losing work at the one point where undo cannot help.
## [0.6.0] - 2026-08-04
Two things the app knew and would not say: who a message was addressed to,
and whether you had read it.
### Added
- **From, To and Cc in the message header.** All three were parsed on every
message and then discarded before rendering. A thread holding one message
now shows them under the subject.
- A thread holding **several** messages still shows only the subject and the
count. From, To and Cc differ per message, and once you have replied there is
no single address the thread is addressed to, so naming one would be a guess
presented as a fact. An empty Cc omits its row rather than printing a label
with nothing after it.
- **A details dialog**, behind a `Details...` button beside the subject or
`Ctrl+Shift+D`, listing Subject, From, To, Cc, Date and Message-Id for every
message in the thread, numbered. Read-only plain text: these values come from
strangers, and the format that cannot interpret markup is the right one for
showing them verbatim.
- **An opened thread is marked read after a delay**, 2 seconds by default.
Arrowing quickly through a list marks only the thread you stop on, never the
ones you pass through. Configurable through `[general] mark_read_delay_ms`:
zero marks read at once, and any negative value turns the behaviour off.
### Notes
The automatic mark-read is deliberately **not** on the undo stack. Undoing an
action you never took is worse than leaving a thread read, and `Ctrl+U` already
puts it back. Marking a thread unread by hand cancels any pending timer, so the
key cannot be silently reversed a moment later.
**HTML messages already opened as HTML**, which a backlog item had doubted.
Verified against real mail; no code changed. No preference was added for
defaulting to plain text, since `Ctrl+H` already switches a thread by hand.
## [0.5.0] - 2026-08-04
Completion in the query bar. The point is not to save typing but to make the
notmuch query language discoverable: every candidate carries a description, so
the bar teaches the syntax to someone who has never written a notmuch query.
### Added
- **Query prefixes** complete with a description each: `tag:`, `is:`, `from:`,
`to:`, `subject:`, `date:`, `attachment:`, `mimetype:`, `folder:`, `path:`,
`thread:`, `id:`, and the `and` / `or` / `not` operators. The list is
hardcoded, since notmuch exposes no way to enumerate its own prefixes.
- **Tag names** after `tag:` and `is:`, which notmuch treats as synonyms. The
list is the real set of tags in the database, refreshed at startup, after a
sync, and whenever a tag mutation introduces one that was not there before.
- **Dates** after `date:`, symbolic and relative, completing each bound of a
`..` range independently. Entries that are themselves open-ended ranges,
like `1week..`, are withheld once a range is already underway, since they
would produce malformed queries inside one.
- **Content types** after `mimetype:`, from a built-in list extensible through
the new `[completion] extra_mimetypes` key. Entries are appended to the
built-ins rather than replacing them, so a typo cannot leave you with fewer
completions than the defaults.
- **Account directories** after `path:`, in both the plain and the recursive
`<maildir>/**` form.
- `complete_query`, bound to **Ctrl+Space**, opens the popup on demand.
- `[general] completion_on_focus`, off by default, opens it as soon as an empty
query bar takes focus.
- Accepting a prefix chains straight into its values, so taking `tag:` offers
the tag list without a second keystroke.
### Fixed
- Return in the query bar ran nothing and moved focus to the thread list.
Return is bound to `open_thread` as a window shortcut, and a shortcut is
dispatched before the focused widget sees the key; Qt withholds plain-letter
shortcuts from editable widgets, but Return is not a letter and got no such
protection. The query bar now claims the key back while it has focus.
- Tab and the arrow keys crashed the application outright while the popup was
open. `QCoreApplication::sendEvent` re-runs application-level event filters,
so the filter forwarding a key to the popup was handed the same key straight
back, recursing until the stack was exhausted.
### Notes
The example configuration in the README had two keys that were live rather
than commented, so copying the block activated a sync command and three
mimetypes the reader never chose. Both are commented now.
`from:` and `to:` complete no addresses: libnotmuch exposes no call to
enumerate them. `folder:` completes nothing either, as a Maildir folder name
is not something the configuration can enumerate. Saved query names are
deliberately absent, a name not being valid notmuch syntax.
## [0.4.1] - 2026-08-03
Packaging only. No change to the application itself.
### Added
- A SlackBuild under `assets/slackbuild/`, with the usual `.info`,
`slack-desc`, `doinst.sh` and `README`. It follows SBo conventions except
for the tag, `_danix` rather than `_SBo`, and the package type, `txz` rather
than `tgz`, since it is not an SBo submission.
- `QTMAILDIR_BUILD_TESTS`, on by default. Turning it off skips the test suite
and its `Qt6::Test` dependency, which a packaging build has no use for.
0.4.0 accepted this flag but ignored it, because the option did not exist in
that tarball; this release is the first where it takes effect.
## [0.4.0] - 2026-08-03
Attachments become reachable. They were parsed all along and there was simply
no way to get at one, and no way to tell a message had any without opening it.
### Added
- A paperclip column marks threads carrying an attachment, so it is visible
without opening the thread. Driven by the `attachment` tag notmuch already
applies, so it costs no extra query.
- The message pane lists attachments behind one **Attachments (N)** button:
a dialog with the message number, filename and size, a **Save** for each,
and **Save all** when there is more than one.
- **Save all** writes into a new subfolder named `<date> <subject>` inside a
folder you pick, so a thread with sixteen files does not scatter them among
whatever is already there. The folder is named in the picker before you
choose, and an existing folder of that name is never merged into.
### Fixed
- **Attachments were unreachable.** The attachment bar had been created and
added to the layout since it was written, and nothing ever populated it.
- **Saving several attachments could destroy files.** Messages in one thread
commonly attach the same filename, and each save overwrote the previous one
while still reporting success: sixteen attachments produced ten files. Batch
saves now add a numeric suffix instead, and keep a compound extension like
`.tar.gz` whole.
- **A `Date:` header carrying a timezone comment lost its date.** `+0200
(CEST)` is legal and common, but Qt rejects the whole header rather than the
comment, so those messages got no date prefix on their folder.
### Changed
- Thread-list column widths reset once on first launch after upgrading. The
saved layout is from before the paperclip column existed, and applying it
would have shifted every width onto the wrong column.
## [0.3.0] - 2026-08-03
The app remembers how you left it. Window, splitter and column sizes survive
a restart, the message pane owns its zoom and keeps it, and startup opens the
query you asked for rather than whichever one sorted first.
### Added
- Window geometry, splitter position and thread-list column widths persist
across restarts. Machine-written state lives in a separate file,
`~/.local/state/qtmaildir/uistate.conf`, and never touches the hand-edited
config: a base64 geometry blob does not belong in a file you edit, and
rewriting that file on exit would drop its comments and key order.
- The message pane owns its zoom, which is remembered across restarts. It was
previously the web engine's own behavior, invisible to the application,
which is why there was nothing to save.
| Gesture | Does |
|---|---|
| `Ctrl++` | Zoom in |
| `Ctrl+-` | Zoom out |
| `Ctrl+0`, `Ctrl+=` | Actual size |
| `Ctrl`+wheel | Zoom in and out |
| `Ctrl`+middle-click | Actual size |
All three actions appear in the View menu and are rebindable through
`[keys]` as `zoom_in`, `zoom_out` and `zoom_reset`. The factor is clamped
to 0.5 - 3.0.
- `[general] message_zoom` sets the starting zoom for a profile that has
never zoomed. Once you zoom, the state file remembers that instead.
- `[general] startup_query` names the saved query to open at startup, and
defaults to `Unread`.
### Changed
- **Startup no longer opens `savedQueries().first()`.** `[queries]` is read
through `childKeys()`, which sorts alphabetically, so the query that opened
was whichever name sorted first rather than one you chose. It is now
selected by name. If your `[queries]` has no entry named `Unread`, set
`[general] startup_query` to the one you want, or you will keep getting the
alphabetically first one. Saved-query button order is unchanged.
### Fixed
- **`[general]` keys were never read.** They were looked up as
`general/<key>`, which matches nothing: QSettings' INI backend treats a
section literally named `[general]` as its own fallback section and strips
the prefix. `notmuch_config` had therefore been silently ignored since it
was introduced. If you set it and wondered why nothing changed, it works
now. The file format is unchanged.
## [0.2.0] - 2026-08-03
Menus, a toolbar and an in-app shortcut reference, so the app is usable
without memorizing keys. Tags render as coloured chips rather than a column
of text. Three default keybindings that had never worked now do.
### Added
- Tags render as coloured chips instead of text in a column. The account tag
sits in front of the subject in the thread list, and the functional tags fill
a single row under the message pane, with anything that does not fit
collapsing into a `+N` chip whose tooltip names the rest.
- `[tagcolors]` config group. Colours resolve by exact tag first, then by
top-level prefix, so one `shopping` entry covers `shopping/amazon` and
`shopping/nike` while `shopping/amazon` can still override its own. Built-in
defaults cover the usual state tags; anything unconfigured gets a stable
colour derived from its name.
- `color` and `label` keys in an account stanza, setting the account chip's
fill and its text. `label` shortens a long key for display only and renames
nothing in notmuch; unset falls back to the key.
- The application icon is now used: window icon, a `.desktop` entry, and
install rules placing both into `hicolor` and `share/applications`.
- Toolbar and menu actions carry icons from the system theme, falling back to
text where a theme lacks one.
- Menu bar covering every action: File, Edit, Message, View and Help.
- Toolbar with the frequent subset, Sync, Archive, Delete and Undo.
- **Help > Keyboard shortcuts**, listing the current bindings. Generated from
the actions themselves, so it shows configured overrides rather than a
hand-written copy of the defaults.
- **Help > About**.
- Default bindings for `spam` and `load_remote`, which previously had none
and were unreachable until bound by hand.
### Fixed
- Acting on a thread now visibly changes its row. A thread tagged `deleted`
or `spam` is filled dark red or orange, in white struck-through text, across
every column. The tag change was already applied, but `Tags` sat after the
stretching `Subject` column and was pushed off-screen, so Delete looked like
it had done nothing.
- Thread list columns are Date, From and Subject, all resizable. The tags
column is gone: spelling out a dozen tags per row consumed most of the list's
width. Widening past the viewport scrolls horizontally rather than squeezing
the other columns.
- Hierarchical tags in `[tagcolors]` were silently ignored. QSettings treats
`/` in a key as a group separator, so `shopping/amazon` becomes a nested key
that `childKeys()` never returns, and every tag containing a `/` fell through
to its prefix.
- Three default bindings never fired. Typing a capital sends `Shift`+the key,
but `N`, `F` and `G` were stored as the unshifted key, which no keystroke
produces, leaving `toggle_unread`, `flag` and `sync` dead. A bare capital in
`[keys]` is now read as `Shift`+that letter. As a side effect `y` and `Y`
are two distinct keys rather than a collision that silently dropped one.
- Modifier shortcuts such as `Ctrl+Q` now work while the query bar has focus.
The old event filter suppressed every binding there, not only the plain
letters that would have interfered with typing.
### Changed
- Default bindings moved to modifier shortcuts. **A `[keys]` section written
for 0.1.0 keeps working and keeps the old keys**, which also means it hides
every new default: delete the section to adopt them, or rebind individually.
Single letters are still safe to bind, since Qt suppresses a plain-letter
shortcut while the query bar has focus.
| Action | 0.1.0 | 0.2.0 |
|---|---|---|
| `next_thread` | `j` | `Ctrl+J` |
| `prev_thread` | `k` | `Ctrl+K` |
| `open_thread` | `Return` | `Return` |
| `archive` | `a` | `Ctrl+E` |
| `delete` | `d` | `Ctrl+D` |
| `spam` | *(unbound)* | `Ctrl+Shift+S` |
| `toggle_unread` | `N` *(never fired)* | `Ctrl+U` |
| `flag` | `F` *(never fired)* | `Ctrl+I` |
| `focus_query` | `/` | `Ctrl+L` |
| `toggle_html` | `h` | `Ctrl+H` |
| `load_remote` | *(unbound)* | `Ctrl+M` |
| `undo` | `u` | `Ctrl+Z` |
| `sync` | `G` *(never fired)* | `Ctrl+G` |
| `quit` | `Ctrl+Q` | `Ctrl+Q` |
Action names are unchanged, so no existing binding becomes invalid.
- Actions are `QAction`s dispatched by shortcut rather than a hash of
callbacks behind an event filter, which is what lets them appear in menus.
The hand-maintained list of registered action names is now derived from the
actions, so it can no longer drift from them.
## [0.1.0] - 2026-08-03
First release. Reads and organizes a local notmuch-indexed Maildir; it does
no network protocol work at all, since fetching and sending are left to
external commands.
### Added
- Permanent notmuch query bar with saved-query buttons and per-account
scoping, over a two-pane thread list and message view.
- Threads streamed from the database in batches of 200, so a query over tens
of thousands of threads paints its first rows immediately and fills in
behind. Measured at 21 ms to the first batch against a 36,000-thread
database.
- Whole-thread rendering: every message in a thread renders into one
document, oldest first, with messages that did not match the current query
collapsed to one-line stubs. The last message always renders expanded, so
a thread is never nothing but stubs.
- HTML mail rendered through QtWebEngine, with a locked-down profile:
off-the-record, no cookies, no cache, JavaScript disabled, and a
deny-by-default request interceptor. Remote content is blocked until the
user asks for it, which defeats tracking pixels and read receipts; the
grant applies to one render and is never remembered.
- Inline `cid:` images served from an in-memory map scoped to the displayed
thread. References are namespaced per message, so two newsletters sharing
a Content-ID do not resolve to each other's images.
- Attachment handling that treats MIME filenames as untrusted: names are
reduced to a basename and refused if the resolved path escapes the chosen
directory.
- Tagging (archive, delete, spam, flag, read/unread, custom) over a
multi-thread selection, applied optimistically and reverted if the write
fails. Thread ids are resolved to message ids in a single combined query
rather than one query per thread.
- Undo for tag changes, backed by a `QUndoStack`. Entries store thread ids
and re-resolve them, so undo stays correct after the selection moves on.
There is deliberately no dry-run and no destructive-action confirmation:
undo is the better answer for a human at a GUI.
- Sync by running the user's existing script through `QProcess`, joining the
`flock` that already serializes it against cron rather than reimplementing
mbsync orchestration.
- Configurable keybindings via the `[keys]` section, validated against a
known action list so a typo warns instead of binding silently.
- `--version` and `--help`.
### Known limitations
- Compose and send are not implemented; they are planned for v2 and need a
companion send script that does not exist yet.
- A thread renders as a flat chronological list. Reply structure is
available from notmuch but is not drawn as an indented tree.
- MIME parsing happens on the UI thread. Opening a very long thread parses
every message in it, which could stutter; deferred until measured.
- The sync log pane shows what the sync command writes to stdout and stderr.
A script that redirects its own output to a file will leave the pane
empty.
|