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
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
|
# Post-0.1.0 usability backlog
Status: **open, expandable by design.** This document is not a fixed release
plan. It collects items found by actually using qtmaildir after 0.1.0, and it
grows as more turn up. Nothing here is scheduled; picking what ships in a given
release is a separate decision.
Source: usage notes taken while running the app, 2026-08-03.
**Numbers here are this document's own.** The user's own notes were numbered
independently and the two sequences drifted apart once items were split: what
those notes called 12 is item 13 here, and item 14 here (the tag column) was
never in them at all. Items 15 to 17 come from a later pass over the same
notes. Cite these numbers, not the notes', and do not renumber to reconcile.
**The notes are the upstream source and they keep growing.** The user adds to
them while using the app, so this document goes stale on its own. Items 28 to 35
came from one such pass on 2026-08-04 and included two defects that had gone
unrecorded here for a while. Items 39 to 45 came from the 2026-08-05 pass, which
found one more defect (41, a message body silently dropped by the MIME walk) and
one item that cannot be planned at all until the user says where the thing it
manages lives (44). Items 121 to 123 came from the 2026-08-20 pass, which found
that item 74 had closed only half of what its note asked for, and that the
README had gone stale enough to document a mandatory config key by omitting it.
Compare the two at the start of a session; the procedure is in `CLAUDE.md`.
Numbering is stable. New items append with the next free number and never
renumber, so a note referring to "item 7" keeps meaning the same thing. An item
that is dropped stays in the table marked `dropped` with a one-line reason.
**The status table below is the index of every item; the sections are only the
open ones.** Item 73 moved the done, dropped and postponed sections out to
`2026-08-03-post-0.1.0-usability-closed.md`, which took this file from just over
five thousand lines to under six hundred. Nothing was deleted and nothing was
renumbered: a closed item keeps its row here, with its date and outcome, and its
full Observed/Cause/Approach section is in that file under the same number. Look
there when a row cites evidence you need.
**Items 20 and 53 are both on master since 2026-08-10**, as the card list. Item
20's original presentation, the one the user rejected on sight, is preserved on
the branch `item-20-message-rows` at 029a50e and was never merged; the branch
`card-list` carries the work that was. Any file or line reference in item 20's
entry, now in the closed-items file, points at that PARKED branch, not at
master, where the same lines are unrelated. Item 53 records why the first
attempt was rejected and is worth reading before changing the thread pane again.
## Theme
0.1.0 was built to a spec written by someone who lives in neomutt. The result
is a keyboard-driven reader with almost no visible affordances. The notes below
are, with few exceptions, one complaint restated in several forms: **the app
does not tell the user what it can do, and it does not remember what the user
told it.** Two clusters follow from that:
- **Persistence.** Splitter position, font size, window geometry, and the
account selection all reset on restart. Each is small on its own and
aggravating every single launch.
- **Discoverability.** Shortcuts are the only route to most actions, and there
is no menu bar, no toolbar, and no way to see the key bindings from inside
the app.
Both clusters are cheap to fix. Neither was an oversight in design so much as a
consequence of specifying the app as "a GUI counterpart to neomutt" and then
taking that too literally.
## Status table
| # | Item | Cluster | Size | Status |
|---|------|---------|------|--------|
| 1 | Splitter/column widths do not survive restart | persistence | S | **done** |
| 2 | No way to see full message details (From/To/Cc/Subject) | information | M | **done** |
| 3 | Too few clickable affordances, shortcuts are the only route | discoverability | M | **done** |
| 4 | Message-pane font size does not survive restart | persistence | S | **done** |
| 5 | Thread list is cramped, poor readability | presentation | S | **done** |
| 6 | Opened message stays unread | behavior | S | **done** |
| 7 | HTML view should be default for HTML messages | behavior | XS | **done** (already worked) |
| 8 | No buttons or menu entries for archive, undo, etc | discoverability | M | **done** |
| 9 | No in-app view of configured shortcuts | discoverability | S | **done** |
| 10 | Reaching an account's inbox takes two steps | workflow | S | **postponed** (partly done) |
| 11 | Icon, `.desktop` file, SlackBuild | packaging | M | **done** |
| 12 | Message pane is light-theme only | presentation | S | **done** |
| 13 | No visual feedback that an action stuck | feedback | S | **done** |
| 14 | Tag column unreadable, tags need another home | presentation | M | **done** |
| 15 | Attachments are parsed but unreachable from the UI | information | M | **done** |
| 16 | Delete on an already-deleted thread should undelete | behavior | S | **done** |
| 17 | No completion for tags in the query bar | workflow | M | **done** |
| 18 | No visual cue that there are unsynced edits | feedback | S | **done** |
| 19 | No prompt to sync on exit when edits are pending | behavior | S | **done** |
| 20 | Thread view does not match the user's mental model | presentation | L | **done** 2026-08-10, as the card list; see 53 |
| 21 | Default shortcuts are not sensible enough | discoverability | S | open; **the user is drafting the table** in their own notes (`qtmaildir shortcuts and menu structure.md`), 2026-08-23. Read it first rather than proposing one. Settles `Ctrl+Return` for Send; leaves two collisions and an unfinished menu half, see the entry |
| 22 | Translatability audit and i18n wiring | correctness | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-i18n-design.md`. Found eight rule-builder labels that could never be translated in any language, and twenty untranslatable warnings. Ships an Italian translation of all 355 strings |
| 23 | No way to save a search query from the UI | workflow | M | **done** 2026-08-13, shipped in 0.18.0; see `specs/2026-08-13-saved-queries-design.md` |
| 24 | No right-click actions on the thread list | discoverability | S | **done** |
| 25 | No select-all, and bulk actions are undiscoverable | workflow | S | **done** |
| 26 | No way to add or remove an arbitrary tag from the UI | workflow | S | **done** |
| 27 | The UI cannot see a sync it did not start | feedback | S | **done** |
| 28 | Re-adding `unread` counts 2 unsynced changes, not 0 | correctness | S | **done** |
| 29 | Sync button stays enabled during a background sync | feedback | XS | **done** |
| 30 | The blank right pane is wasted space | presentation | M | **done** |
| 31 | The quit prompt has no highlighted default button | discoverability | XS | **done** |
| 32 | Esc does not blank the right pane | workflow | XS | **done** |
| 33 | Status bar messages never expire | feedback | S | **done** |
| 34 | No overview of the Maildir itself | information | M | **done** |
| 35 | No refresh of the thread list after a sync | workflow | M | **done** 2026-08-10; the list now follows a sync on its own |
| 36 | `test_mainwindow` cannot reach the worker | testing | S-M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-mainwindow-worker-fixture-design.md`. `WorkerBackedWindow`, opt-in per test, no production change. Its first use ruled out the simple case of item 66 |
| 37 | The worker stalls on a tag edit made during a background sync | correctness | S | **done** |
| 38 | `test_mainwindow` fails when a real sync holds the lock | testing | XS | **done** |
| 39 | Thread list cannot be sorted by clicking a column header | workflow | S | **dropped** 2026-08-10; the card list has no column headers to click, and 0.13.0 shipped a sort dropdown instead |
| 40 | No live filter over the current view | workflow | M | open |
| 41 | A message whose HTML body carries a `Content-Id` renders blank | correctness | S | **done** |
| 42 | "Syncing..." says nothing about what is being synced | feedback | S | **done** |
| 43 | No "Mark all read" for the current view | workflow | S | **done** |
| 44 | No way to manage the filters applied at sync time | workflow | M | **done** 2026-08-13; see `specs/2026-08-12-tagging-rules-design.md`. Spans this repo and `mailctl` |
| 45 | Two Sync buttons, and only one of them works properly | correctness | S | **done** |
| 46 | `uiStateSurvivesARestart` fails under the offscreen platform | testing | XS | **done** |
| 47 | The query bar looks unfinished, and cannot be cleared by mouse | presentation | XS | **done** |
| 48 | Removing a tag suggests every tag, not the thread's own | workflow | XS | **done** |
| 49 | Sync runs every account regardless of what changed | workflow | M | **done** |
| 50 | Esc blanks the pane but leaves the row selected | workflow | XS | **done** |
| 51 | Clicking a subject scrolls the list sideways | presentation | XS | **done** 2026-08-10; a card is viewport width, so there is nowhere to scroll |
| 52 | `test_querycompleter` fails under Wayland, passes offscreen | testing | XS | **done** |
| 53 | Message rows still read as a table, not as a conversation | presentation | M | **done** 2026-08-10, merged to master as the card list |
| 54 | A cron sync carries the edits but the count still says pending | correctness | S | **done** |
| 55 | In a narrow window the message pane is invisible | presentation | XS | **done** |
| 56 | No action carries an icon, so the toolbar reserves space for nothing | presentation | S | **done** |
| 57 | "Flag" would read better as "Important" or "Starred" | presentation | XS | **done** |
| 58 | `message_zoom` documents a 0.5 to 3.0 range and enforces none of it | correctness | XS | **done** |
| 59 | Archive and Mark all read shipped with the same icon | presentation | XS | **done** |
| 60 | Next thread dead-ends on the last reply of an expanded thread | defect | XS | **done**; already fixed by 5487d58, see the closed-items file |
| 61 | `test_mainwindow` fails intermittently, about 1 run in 20 | testing | S | **done** 2026-08-13; an `init()` fixture points every test at its own lock table |
| 62 | No config option for the date format on a card | presentation | XS | **done** 2026-08-11 |
| 63 | No way to see sent mail, and no filter for it | workflow | M | **done** 2026-08-11; see `specs/2026-08-11-sent-mail-design.md` |
| 64 | The Sync button carries a mailbox icon, not a refresh one | presentation | XS | **done** 2026-08-11 |
| 65 | No full code review and optimization pass | correctness | ? | open, **narrowed 2026-08-26**: the notes now name it as a dead-code and duplication sweep, not a performance or security pass. Produces a LIST for the user to decide on, not a diff. See the entry |
| 66 | Selecting a thread root leaves the message pane blank until a reply has been selected | defect | S | **done** 2026-08-14, unreleased. Not the blank pane it was filed as: the root rendered the CONVERSATION until the thread had been expanded once, then one message. Now always one message, and the conversation view is removed at the user's request. **One case unverified by hand:** the notes also report a single-message `id:` query whose card would not open, which is the same empty-`MessageIdRole` failure and should be gone; confirmed 2026-08-15 as a SEPARATE defect with a different cause, see item 96 |
| 87 | Auto mark-read marks a whole thread, including replies never displayed | defect | S | **done** 2026-08-16, unreleased. Built on 108, which is why it stayed small: the timer tracks a MESSAGE id now, and arms for a reply too, which it never did before |
| 88 | `threadAt(current.row())` answers about the wrong thread for a reply row | defect | M | **done** 2026-08-16, unreleased. The audit found FOUR live sites, not one. `ThreadListModel::threadFor(index)` resolves a reply through its parent; every caller holding a selected index converted, and no `.row()` on a selected index remains in `mainwindow.cpp`. Unblocks 87 |
| 67 | The placeholder pane counts unread, flagged and inbox, but not sent or drafts | information | XS | **done** 2026-08-11, shipped in 0.15.0 |
| 68 | A forwarded subject gets no `passed` tag | workflow | S | **done 2026-08-26**, unreleased, as THREE things once the premise was measured away. The note asked to expand a subject rule to `Fw:`; there was no subject rule, and the correlation it rested on did not exist. What did exist was a gap nobody had reported: qtmaildir has never written `R` or `P`, so a reply and a forward now flag their source (off the undo stack, per the auto-mark-read precedent), and `subjectIsForwarded()` drives a SEPARATE received-forward mark, display only, extendable through `[general] forward_prefixes`. The user chose all three |
| 69 | `passed` and `replied` read as words where every other state is a glyph | presentation | S | **done** 2026-08-11, inside item 70 |
| 70 | Pane icons are a private set where the main window uses the system theme | presentation | M | **done** 2026-08-11; six shipped SVGs |
| 71 | A toolbar action does not sync, so the edit sits until the next cron run | workflow | S | **done** 2026-08-11; 2s default, `auto_sync_delay_ms` |
| 72 | No khard/khal integration | workflow | ? | open, unspecified; the user places it after send, so v2 at the earliest |
| 73 | This backlog is past four thousand lines | maintenance | S | **done** 2026-08-13; 5056 lines to 578, closed sections moved to `2026-08-03-post-0.1.0-usability-closed.md` |
| 74 | "Searching..." keeps claiming a query is running while rows are already arriving | feedback | XS | **done** 2026-08-15, unreleased. The status-bar half only: the bar now counts threads per batch. The cold-cache delay itself was measured in 2026-08-11 and is not fixable here |
| 75 | The tagging rules window forgets its size and its column widths | persistence | S | **done** 2026-08-13, shipped in 0.17.0. The window-kind question is left open, see the closed-items file |
| 76 | Every field in the rules dialog is free text, so a rule is easy to get wrong | workflow | M | **done** 2026-08-13, shipped in 0.17.0. See `specs/2026-08-13-rule-builder-design.md` |
| 77 | No way to see what a rule would collect, in the thread list | workflow | S | **done** 2026-08-13, shipped in 0.17.0 |
| 78 | No way to build a rule from something visible in a message | workflow | S | **dropped** 2026-08-17 at the user's request. Never a defect: item 85 built the road (right-click any value, search it, save the query) and item 81 the last step (a rule from a saved query), so the whole journey is available. This was only a shortcut across it, and the entry had already said to use 85 for a while before deciding which values were worth promoting. Reopen if that use turns up a value worth a one-click rule |
| 80 | A rule with many conditions squeezes the rule list to one visible row | defect | XS | **done** 2026-08-13, shipped in 0.17.0. Follows item 76 |
| 79 | Opening the rules dialog and saving destroys the first rule | defect | XS | **fixed on `rule-builder`** 2026-08-13, unreleased. Shipped in 0.16.0; damaged one real rule, repaired by hand |
| 81 | No way to turn a saved query into a tagging rule | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-query-to-rule-design.md` |
| 82 | A saved query cannot be edited, unpinned or deleted from the UI | defect | S | **done** 2026-08-13, shipped in 0.18.0. Right-click offers Edit, Pin/Unpin and Delete |
| 83 | A rule named with spaces is written to the file and dropped by every reader | defect | S | **done** 2026-08-14, unreleased. The name is sanitised into an id, save validates, a bad id loads for repair |
| 84 | A config problem blocks `test_mainwindow` on a modal nobody can dismiss | testing | S | **done** 2026-08-14, unreleased. `showWarnings()` split: the status label stays in the constructor, `main.cpp` raises the modal after `show()` |
| 85 | Nothing on screen can be searched for by right-clicking it | workflow | M | **done** 2026-08-14, unreleased; see `specs/2026-08-14-search-from-message-design.md`. Split from 78; rebuilt the details dialog as rows |
| 86 | A right-click search can replace or narrow, but never exclude | workflow | S | **done** 2026-08-14, unreleased; see `specs/2026-08-14-exclude-from-search-design.md`. Follows 85. The `extend` bool became a `SearchMode` enum across four signatures |
| 89 | A sync moves the list under the user's hands, and the auto-sync skips rather than retries | workflow | XS | **done** 2026-08-15, unreleased. The timer half only: a skipped auto-sync re-arms instead of giving up. The list-churn half is **dropped**, not built: the user resolved it as a mental-model question, an Unread view is SUPPOSED to be volatile |
| 90 | A saved-query button clears the account selection | workflow | S | **folded into 93** 2026-08-15. Not fixed in place: the button that misbehaves stops being a saved query at all. See `specs/2026-08-15-builtin-filters-design.md` |
| 91 | Double-clicking a thread should open it on its own | workflow | S | **done** 2026-08-15, unreleased. The view is always the whole thread, EXPANDED; the pane shows whichever row was double-clicked, so a reply drills to its thread and not to itself. Reuses `recoverStaleThread()` outright |
| 92 | Nothing distinguishes a tag written by a rule from one the user applied | information | M | **postponed** 2026-08-15 at the user's request: "I don't see the utility, so I don't really know how to answer." Needs per-MESSAGE provenance nothing records, a two-repo format change blank on all existing mail. Reopen only if the need appears in use |
| 93 | The query buttons are whatever the user pinned, not a designed set of filters | workflow | M | **done** 2026-08-15, unreleased; see `specs/2026-08-15-builtin-filters-design.md`. Absorbs item 90. Four built-in filters composing with the account dropdown; the user's own queries unpinned, never deleted |
| 95 | A query in the overflow menu cannot be run | defect | XS | **done** 2026-08-15, unreleased. Pre-existing and not caused by 93: the entry's action owned a submenu, and Qt emits no `triggered` for those, so the connection had never fired. Surfaced because 93 moved every query into the menu |
| 94 | `pinned` has nothing left to decide once the buttons are built-in | maintenance | S | **done** 2026-08-24, unreleased. The query row is the six built-in filters only, every saved query is in the menu, and `SavedQuery::pinned` is gone from the struct, the reader, the writer, the save dialog's checkbox and the pin/unpin context action. The user chose to **strip** the stored key rather than leave it ignored, against this entry's own preference, so `pinned` stays named in the reader's `known` list precisely so it is NOT preserved as an unknown field and written back. Confirmed with the user first that the built-in set covers their use, since removing pinning removes the escape hatch this item was blocked on. Six tests reached saved queries through buttons that no longer exist and were converted to the menu, two more replaced outright (`onlyPinnedQueriesBecomeButtons` and friends), and `migrationPinsEveryEntry` / `aStoredGeneratedQueryIsUnpinnedNotDropped` were rewritten around the property that survives: an entry must be KEPT, which is what those assertions were really guarding. Three translated strings retired, `lrelease` reports 479 finished 0 unfinished |
| 96 | A query returning the thread already on display opens onto the placeholder | defect | S | **done** 2026-08-15, unreleased. Split from 66's unverified half, which had a different cause. Reproduced from two screenshots after four measured eliminations |
| 97 | An edit made during a sync is reverted in the list when the sync ends | defect | S | **done** 2026-08-15, unreleased. Found by hand-testing item 89's fix. The sync-end refresh ran BEFORE the held-edit flush, so it read a database that still carried the old tag |
| 98 | "Important" adds the tag but cannot remove it, unlike every other toggle | defect | XS | **done** 2026-08-17, unreleased. Calls `everySelectedRowHasTag()`, as the entry required. Its reply test needed THREE different states (list-first thread, the reply's own thread, the reply) before it could tell the two wrong answers apart; with the reply defaulted to its thread's state the item 105 mutation stayed green, measured |
| 99 | The unread action is labelled "Toggle unread" whichever way it will go | presentation | S | **done 2026-08-25**, unreleased, with 112: the user's note is ONE design across both. The label names the direction it will go, and the entry is hidden on a selection with no single state. `refreshUnreadAction()` reads the new three-valued `selectionTagPresence()` |
| 100 | The message pane offers Back, Forward, Reload and Save page, none of which mean anything | defect | XS | **done** 2026-08-17, unreleased. `MessageView::removeBrowserActions()` filters the standard menu by `pageAction()` POINTER, never by text; `ViewSource` went with them, and stranded separators are swept |
| 101 | Sync is account-aware for edits but not for the account the user is looking at | workflow | S | **done 2026-09-08**, unreleased. The user chose both halves the entry offered and narrowed the first: no second action, the existing Sync reads the account dropdown. Selected account narrows the run, All accounts is a full fetch as before, and the edited accounts are a UNION with the selection so a narrowed run cannot strand a write. The status line names what a run covers. See the closed entry |
| 102 | The rules table shows no note, so the field explaining a rule is invisible until it is opened | workflow | XS | **done** 2026-08-17, unreleased. A Note column before `ColumnCount`, so the appended Matches column stays last. Found a second defect on the way: `restoreState` REFUSES a header state with a different column count, and the sized flags were being set regardless |
| 103 | What Delete does to mail on the server is undocumented and unverified | clarification | S+M | done; Delete moves to the account trash, with Restore and a stranded-mail cleanup. Section in the closed file |
| 104 | Mail visible in Thunderbird never reaches qtmaildir | defect | XS | **done 2026-08-25**, hand-tested. The worker never reopened its read-only notmuch handle, so no query saw mail indexed after startup. Confirmed on a sync run from the application that added 20 messages: they appeared without a restart |
| 109 | A root card's own message is invisible to a message-scoped write | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 108. `applyMessageTagChange` and `messageById` searched only the loaded replies, and a root's message is never among them, so the ORDINARY gesture repainted nothing and wiped the pane's chip row |
| 110 | A card and the message pane show tags belonging to a message's siblings | defect | S | **done** 2026-08-16, unreleased. Found by hand-testing 109 against a real 4-message thread. `ThreadSummary::tags` is notmuch's UNION; a card standing for one message drew it. Also the reason a root card could not repaint at all |
| 111 | A card should show its siblings' tags smaller, not drop them | presentation | S | **done** 2026-08-16, unreleased. The user's own design, from looking at 110's result: own tags full size, the thread's others smaller and muted, so nothing appears to vanish on selection |
| 105 | Acting on a reply changes the counter and nothing on screen | defect | M | **done** 2026-08-16, unreleased. Found by hand-testing 88, and took three passes. FOUR causes: no optimistic update for a message-scoped write, no doomed cue on a reply row, both toggles reading the reply's THREAD state so they were one-way, and the message pane's strip not following a message edit. Also bolds an unread reply, at the user's request |
| 106 | A tag change made on one message during a sync is silently lost | defect | XS | **done** 2026-08-16, unreleased. Found by READING while fixing 105, never reported. `flushHeldEdits` re-sent only thread-scoped edits, so a message-scoped one was shown, counted as pending, and never written |
| 107 | A thread-scoped write leaves the loaded replies showing their old tags | defect | XS | **done** 2026-08-16, unreleased. `applyTagChange` updated the summary only, so marking a thread read left its expanded replies bold |
| 108 | Acting on a thread root means the whole thread, though it displays one message | workflow | M | **done** 2026-08-16, unreleased. `messageScopeFor()` beside `scopeFor()`; five `*_thread` actions in a "Whole thread" submenu on `Ctrl+Alt+<key>`. User-visible: minor bump, `### Upgrading` written |
| 112 | Toggle unread on a whole thread cannot reach "all unread" on a partly-read thread | defect | S | **done 2026-08-25**, unreleased. Built to the user's own note rather than to this entry's approach, which had it only half right. The thread toggle splits into two absolute actions AND the message-scoped one keeps its toggle with a dynamic label, hidden when the selection disagrees. Closes 99 and 147 with it |
| 113 | No way to see a message's HTML source | information | S | open, 2026-08-17. Chromium's own View source cannot work here; needs our own plain-text dialog. Item 100 removed the dead entry, which was an overreach: the user had not asked for it |
| 114 | Save image is offered on every image and does nothing | defect | S | open, found 2026-08-17, re-confirmed by hand 2026-08-20. No `downloadRequested` handler exists, so the request is emitted and never answered. The handler is per-profile, so it must decide per request or it revives the Save link item 127 removed |
| 115 | A copy from the message pane gives no confirmation | presentation | XS | **done** 2026-08-19, unreleased. Four entries report, each naming what it copied; connected to the page's own QActions, so the entry is covered wherever it is triggered from |
| 116 | Copy image copies markup instead of the image | defect | XS | **dropped** 2026-08-17, same day. NOT A DEFECT: `wl-paste --list-types` run immediately after a copy reports `image/png`, `application/x-qt-image` and 30 more image flavours. The clipboard is correct and Chromium is behaving. The earlier "text only" reading was taken minutes late off a clipboard that had been overwritten, and a whole cause was theorised on it |
| 117 | The message pane offers no Select all | workflow | XS | **done** 2026-08-19, unreleased. `addPaneActions()` supplies it. The call site is NOT covered by a test and cannot be: the production menu needs a real context-menu event. Stated in the test rather than faked |
| 118 | No way to empty the trash from inside the app | workflow | S | **done 2026-08-25**, unreleased. Unblocked by 103. `Message > Empty trash...`, scoped to the account selector, no shortcut. The one confirmation in this application, and CLAUDE.md now records it as the single exception rather than leaving it to be discovered. Found a defect while testing: the count claimed messages whose files were already gone |
| 119 | The unsynced-changes count cannot be opened to see what it counts | information | S | **done** 2026-08-26, unreleased. **The stated blocker was not real**: the fourth term counted confirmed changes with no message ids, and `applyTags()` returns early on exactly that condition, so it could never fire. Measured before removing it, not read. The label opens a read-only list, grouped as the user asked: subject once, actions beneath. Scope follows the ACTION, so a held thread edit stays one thread row and reports its message count. A snapshot, frozen once open |
| 121 | The thread list shows nothing while a query is running | feedback | S | open, 2026-08-20, from the notes. Follows item 74, which fixed the status-bar half and left the list itself blank |
| 122 | The README documents a version of the app that no longer exists | documentation | M | **done** 2026-08-23, unreleased, inside item 123 task 13. `trash`, `send_command` and the whole `[compose]` section were undocumented; a Composing section is added and "sending is not implemented" removed. Every default was read from `config.h` rather than from the prose, which caught `send_html` documented as false when it defaults to true |
| 123 | Sending mail is not designed | v2 | L | **specified** 2026-08-20, on branch `compose-and-send`. Design in `docs/superpowers/specs/2026-08-20-compose-and-send-design.md`; read that, not this row. Send is a per-account `send_command` on stdin, so the no-network-protocol rule stands. Composer is a separate window, body is markdown via cmark-gfm, drafts autosave to the account's drafts folder. Tasks 1 to 13 of 13 built 2026-08-20 to 2026-08-23; task 13 closed the documentation out and retired the v1/v2 split, which semver had made meaningless. **Hand tested 2026-08-22 and 2026-08-23** against a fake send command: New, Reply and Forward all send, a forwarded attachment survives intact, and the sent copy is filed. Found two defects, both fixed (the orphaned composer, and sent mail carrying `inbox`). Twenty-two defects were found in the plan's own draft code across tasks 4 to 12, so treat every code block in it as a draft |
| 124 | The worker reads the index directory as the mail root | defect | S | **done** 2026-08-20, unreleased. `mailRootOf()` over `NOTMUCH_CONFIG_MAIL_ROOT`, correct under both layouts. Verified by migrating the developer's own index to NVMe the same day: cold start 38.6 s to 0.67 s |
| 125 | A skipped sync leaves the spinner running for ever | defect | S | **done 2026-08-29**, unreleased, in two halves and mostly already built. The exit-75 branch in `onSyncFinished()` predates this session: spinner cleared, no error, no log pane, exit prompt handled, with a test. Item 174 added the external half, a `skipped` state the application can see. What was genuinely missing was the RE-ARM: `runAutoSync()` re-arms when it declines to start (item 89), but a run that LAUNCHES and exits 75 lands in `onSyncFinished()`, which armed nothing, so the edit waited for a manual sync or the next cron tick. Section in the closed file |
| 126 | A link with `target="_blank"` does nothing when clicked | defect | S | **done** 2026-08-20, unreleased. `createWindow()` returns a relay page that receives the navigation, hands the URL to the browser and refuses. The URL cannot be read in `createWindow()` itself, which is why a relay rather than a lookup |
| 127 | A link's context menu offers four browser actions that cannot work | defect | XS | **done** 2026-08-20, unreleased. Three Open-in actions removed, `CopyLinkToClipboard` kept. Item 126 made them more dangerous rather than less: with a real `createWindow()` they would have started working |
| 128 | No outbox: a send with no network fails instead of queueing | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** The seam is designed in (`MessageSender` is the one funnel), so this wraps it rather than reworking it. Needs its own indicator story first: items 18, 19, 28 and 54 are all an indicator lying, and 125 is one still open |
| 129 | No inline images in a composed message | v2 | M | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Wanted by the user. `cid:` from the HTML part with `multipart/related` nested inside the alternative, the most nesting-heavy part of MIME assembly, and markdown offers no syntax for it |
| 130 | A message cannot be attached to another message directly | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `message/rfc822` part, which GMime builds natively. The manual route exists from 123's first commit: `save_message` writes the `.eml` and it is attached as a file |
| 131 | The markdown dialect and extensions are fixed | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** Configurable in the shape Hugo's config uses. Deliberately fixed initially: CommonMark plus autolink, strikethrough and tasklist |
| 132 | Every action must have a shortcut, and that no longer serves | policy | S | done, 2026-08-20. `everyActionHasAShortcut` is deleted and nothing replaces it: `everyActionIsReachableFromAMenu()` is the required rule and a shortcut is now a chosen subset. Nothing else needed changing, since `showShortcutReference()` already printed `(unbound)` for an empty sequence. Verified by unbinding `tag_rules` and running the suite green, which would have failed before |
| 133 | The composer shows no markdown syntax highlighting | v2 | S | open, 2026-08-20, from the item 123 brainstorm. **Blocked on 123.** A `QSyntaxHighlighter` over the composer's editor, so `**bold**` reads as bold while the buffer stays plain markdown. Standard Qt, no dependency. Deliberately after 123's formatting toolbar: agreeing with the grammar about nesting and about code spans suppressing what is inside them is the expensive part, and the toolbar is what makes the feature usable . **Subsumed by item 173** 2026-08-27: that is a rich-text editor, this is the cheap answer to the same want. Build one or the other, never both |
| 134 | The busy indicator is built inline and is about to be built twice | maintenance | S | done, 2026-08-20, af902e0. `BusyIndicator` (`src/busyindicator.h`) carries both modes: `MainWindow` uses the indeterminate one, and item 123's send popup takes the determinate half for its undo countdown, switching the same widget over when the command starts. Only the BAR was extracted, not the status label this row paired with it. `m_statusLabel` has 34 uses across `MainWindow` for transient messages, selection counts and sync phases, so it belongs to the window rather than to the indicator, and the send popup owns its own phase text |
| 135 | The formatting toolbar's buttons stack rather than toggle | v2 | S | open, 2026-08-21, asked for by the user during item 123 task 8 and reverted the same session. **A spec change, not a defect**: it conflicts with spec:236 ("deliberately no live toggle") and spec:187-190. Both sites need amending FIRST, and the amendment must resolve what replaces bold-then-italic, which is the gesture spec:187's preserved selection exists to serve and which a toggle makes unreachable. That question is the work; the state machine is understood and written up in the section |
| 136 | `undoMovesTheMessageBack` fails when run ALONE, passes in the full suite | defect | ? | open, 2026-08-21, re-measured 2026-08-24 and it is not what the row said. Filed as an intermittent race (1 in 6); it is in fact **deterministic on the selection**: 6 failures in 6 when named on the command line, and, as of 2026-08-24, it fails in the FULL run too: measured at 58f13ad with the day's work stashed out, 274 passed and this one failed. The "passes in the suite" half of this row is therefore no longer true, and the selection-dependence it was named for may not be either. Re-measure before theorising. All three of its 15s `QTRY` timeouts expire, giving 45s against a 25s whole-suite run, so undo never moves the file rather than losing a race. A test that needs its predecessors is the likely shape (the `init()` lock-table fixture of item 61 is one candidate), which makes it a TEST defect until shown otherwise. Not caused by item 149, and re-confirmed 2026-08-24 as not caused by item 152 either, by running the test at the preceding commit in a throwaway worktree. The assertion that fails names the real question: the restored file is in NEITHER `cur` nor `new` of the account inbox, so establish where it went before theorising about a race |
| 137 | A reply to a message that arrived at two accounts can come from the wrong one | defect | S | open, 2026-08-22, found while building item 123 task 12. `ComposeContextBuilder::accountForReply()` takes `messagePaths` PLURAL to disambiguate, and nothing upstream ever gives it more than one path, so the disambiguation is inert |
| 138 | No Drafts filter beside Sent and Trash | workflow | S | **done** 2026-08-24, unreleased. Smaller than sized: `Account::draftsQuery()` and `Config::allDraftsQuery()` already existed for the placeholder pane's count, so only the `kQueryGenerators` entry, the two `resolvedQuery` branches, the label and an icon were missing, and `builtinFilters()` derives the row from that set. Follows TRASH rather than Sent: folder-matched like both, but NOT flat, since a draft reply belongs with the conversation it answers. An account with no `drafts` key shows no button at all, per item 103's rule, which the existing row test surfaced by failing until its fixture configured one |
| 139 | Forward is reachable only from the Message menu | discoverability | XS | **done** 2026-08-24, unreleased, inside 140/141 as that entry said it would be. Forward is on the message pane's own bar with Compose and Reply |
| 140 | Compose, Reply and Forward belong over the message pane, not on the main toolbar | presentation | M | **done** 2026-08-24, unreleased, with 139 and 141, then REVISED the same day after the user looked at it. Reply and Forward move; **Compose stays on the main toolbar**, because the split that survives contact is what the action NEEDS rather than what it is about, and composing needs no message at all. The moved actions leave the toolbar rather than gaining a second home. Same `QAction` objects shown twice over, never copies, so enablement and the menu entries stay single-sourced |
| 141 | The message pane has no button bar of its own | presentation | M | **done** 2026-08-24, unreleased, with 139 and 140. The design question the entry flagged was settled with the user: message actions left, view controls right, separated by an expanding spacer, with `toggle_html` the first of the latter. It sits directly above the web view, BELOW the subject and details rows, which was the user's correction after seeing it at the top of the pane read as window chrome. Icons are 7/8 of `toolbar_icon_size` (28 against the user's 32), derived so the relation survives a change to that key. `MessageView::setBarActions()` is the seam, so the pane still knows nothing about `MainWindow`'s action map. Two traps: a toolbar has no `addStretch()`, and `noTwoActionsShareAnIcon` took an UNNAMED `findChild<QToolBar*>` which now has two candidates, so it is pinned to `main_toolbar` or it would assert against the wrong bar and pass while the rule went unchecked |
| 142 | The composer's formatting buttons share a toolbar with Send and Attach | presentation | S | **done** 2026-08-24, unreleased, with 143/144/145 to the user's own layout. The one `addToolBar` is gone: the composer has no window toolbar at all. Formatting is a `QToolBar` WIDGET in the central column directly above the editor, Send is a big icon-above-text button beside the headers, Attach and the HTML toggle ride the right end of the editor bar, Remove attachment sits with the attachment list. The constraint this entry named came true: the strengthened send-lock test caught Attach live during a countdown, and then a SECOND fault the entry did not predict, see the section |
| 143 | The formatting buttons are text, where every editor uses icons | presentation | XS | **done** 2026-08-24, unreleased, inside 142. `QIcon::fromTheme` per CLAUDE.md's chrome rule, the words kept as the tooltip, and an action whose theme lacks the name keeps its text rather than rendering an empty button |
| 144 | "Also send a formatted copy" is prominent and does not say what it does | presentation | XS | **done** 2026-08-24, unreleased, inside 142. "Send as HTML", icon and text, alone at the right end of the editor bar where it reads as a control of the editor rather than as a formatting button. The Italian entry was refreshed with it, and `lrelease` reports 477 finished, 0 unfinished |
| 145 | Cc and Bcc are permanent rows on every composer | presentation | S | **done** 2026-08-24, unreleased, inside 142. A `QToolButton` disclosure beside To:. `revealCcBccIfUsed()` is the load-bearing half the entry called for: it only ever SHOWS, never hides, so nothing but the user's own click can make a field holding an address invisible. `ComposeContext` carries no `bcc` at all, so the seeded-Bcc case can only arrive from a reopened draft, which is what its test drives. The LABEL is hidden with each field: a `QFormLayout` holds the two as separate items, so hiding the line edit alone strands a `Cc:` over empty space |
| 146 | The unsynced-changes count cannot be opened to see what it counts | information | S | **done as 119** 2026-08-26. Duplicate, recorded 2026-08-23 from the notes; closed by the same work |
| 147 | Toggle unread reads the same whichever way it will go | presentation | S | **duplicate of 99**, recorded 2026-08-23 from the notes, and closed with it on 2026-08-25 |
| 148 | Ctrl+W does not close the composer | discoverability | XS | **done** 2026-08-24, unreleased. A `QAction` parented to the composer, so it is a WindowShortcut dispatched to the active composer only and the main window's namespace is untouched, exactly like the formatting shortcuts. It calls `close()` rather than doing anything of its own: `closeEvent()` already decides whether the draft is saved, and a second route out that skipped it would lose the message. Not registered in `KeyMap`, so item 132's rules do not apply |
| 149 | A reply's cursor lands on the attribution line, not on blank space | defect | XS | **done** 2026-08-24, unreleased, in TWO passes. The first fixed the cursor within each branch (`End` under Above, `Start` under Below) and the user still saw the old layout, because the branches were already right and the DEFAULT was wrong: `above` shipped, and the layout asked for is what `below` produces. Default flipped, and the composer now focuses the body whenever To: is already filled, which a Reply and a Forward always are. Both halves were invisible to the existing `theQuotePositionDecidesWhereTheQuoteLands`, which asserts the quote's position and never the cursor's |
| 150 | The receive-only ribbon stays up after the message that raised it is gone | defect | S | **done** 2026-08-24, unreleased. One line in `MessageView::clear()`, beside the blocked-content bar, the stale notice and the attachment bar it already reset by hand. Only `setReceiveOnlyAccount()` hid the ribbon, which every SELECTION change reaches, so a row-to-row move was never the reproducer: it survived the FOUR routes that blank the pane without one (`clear_pane`, `clear_selection`, a new query, a multi-row selection). The first test written for it passed against the defect for exactly that reason |
| 151 | The message-pane bars blend into the UI and carry no severity | presentation | S | **done** 2026-08-24, unreleased. Two severities as the user asked: yellow for a warning that only explains (the receive-only ribbon), blue for one offering an action (remote content blocked, stale thread), each with its own light and dark set read off `QPalette::Base` as `HtmlBuilder` does. The blocked row had to become a WIDGET first: it was a bare `QHBoxLayout`, which has nothing to paint a ground on, and its six `hide()` sites then had to move to the wrapper or a painted empty strip would show. Both action bars put the button right of a stretch |
| 152 | Signatures are not managed at all | v2 | S | **done** 2026-08-24, unreleased. One markdown file per signature under `~/.config/qtmaildir/signatures/`, spliced into the composer buffer by the `Signatures` namespace and chosen from a `QToolButton` switch on the editor bar. `[compose] signature` seeds a new message, `[account.<key>] signature` overrides per account, `[compose] signature_position` picks end or above_quote. `MessageBuilder` is untouched: it already derives both parts from one string, the transparency the user asked for, and the per-account key does not break "not tied to an account", since an account SEEDS the choice while the switch keeps every signature reachable. A hand test caught one guard defect (a trailing newline defeated the replace guard); fixed and regression-tested |
| 153 | A draft cannot be opened for editing, so it is write-only | defect | M | **done** 2026-08-24, unreleased. `ComposeContextBuilder::forDraft()` reads a draft back into a context; a new `Kind::Draft` seeds the fields verbatim, takes the body with no quote framing, and carries `draftPath` so the autosave REPLACES the file instead of leaving a second copy. `MimeParser` gained `bcc`, which nothing read before: `MessageBuilder` writes Bcc into the draft deliberately, so a resumed draft that ignored it would silently drop every blind recipient. Reachable by double-click and by an `edit_draft` action, gated on the file being in a configured drafts folder because opening ordinary mail this way would make the first autosave DELETE a received message. Found a live defect on the way, see the section |
| 154 | No read confirmation | v2 | ? | open, 2026-08-24, from the notes. `Disposition-Notification-To`, which is a header `MessageBuilder` would add and a request the message pane would have to honour or ignore on the receiving side. Unspecified: whether this is send-side only, and what the reader is asked |
| 155 | No urgency switch on an outgoing message | v2 | S | open, 2026-08-24, from the notes: low, regular, high. `X-Priority` and `Importance`, headers `MessageBuilder` adds; regular writes neither. A control in the composer, and the same question item 144 answered for the HTML toggle applies to where it sits |
| 156 | No delivery confirmation | v2 | ? | open, 2026-08-24, from the notes. Distinct from 154: this is a DSN (`Return-Receipt-To`, or the ESMTP NOTIFY parameter), which is the sending server's to honour rather than the reader's client. Whether it can be requested at all depends on the `send_command`, so this may not be this application's to offer |
| 157 | A draft on display offers Reply and Forward, not Edit | workflow | XS | **done** 2026-08-24, unreleased, and the half item 153 did not close. `populateMessageBar()` swaps the reply pair for `edit_draft`, refilled from `updateComposeActions()` so it follows the message. **Took three hand-test rounds, each finding a defect the tests could not see.** First version shipped item 150's trap one level up: it keyed on `currentIndex()`, which a query leaves VALID on a row of the discarded result, so the bar kept the draft button after clicking Inbox and the reply pair after clicking Drafts. It answers from `m_currentMessageId`/`m_currentThreadId` now, which every blanking route clears, refilled from `showPlaceholderPane()` — the one site all five of those routes share. That exposed a THIRD defect nobody had reported and which predates the bar: enablement ran only from the two selection handlers, so Reply and Forward stayed **enabled over a blank pane**, invisible while they sat on the main toolbar among always-on actions. The bar is then HIDDEN over an empty pane (`!m_items.isEmpty()` in `MessageView::setBarActions`): the user first chose a greyed-out bar, then reversed it on sight for a better reason, that the subject and details button already vanish and a persisting bar was the only piece of header furniture that did not. **The hiding half broke the showing half**, found by hand again: `setBarActions` is called from `updateComposeActions()`, which runs BEFORE `showThread()` fills `m_items`, so the first message opened after any blanking left the bar hidden and the second showed it, reading `m_items` still holding the first — one selection behind for the life of the view. `updateHeader()` shows it, beside the details button it rides with. The test missed it by asserting before the render landed, measuring the placeholder; it waits on `showingPlaceholder()` now. Several guards and `hide()` calls were written across the three rounds and then measured dead, and removed |
| 158 | A freshly saved draft is invisible until a sync indexes it | defect | S | **done** 2026-08-24, unreleased. `saveDraftNow()` emits `draftSaved`, which `MainWindow` connects to a new `NotmuchWorker::indexDraftFile()` that indexes the one file (previous revision removed, so a rewrite leaves no ghost), and `draftRemoved` drops the entry when a sent draft is unlinked. Measured: `index_file` assigns NO tags, so no stripping and no tag:inbox leak. See the section |
| 159 | The Drafts view lists threads, so a draft is unreachable by double-click | defect | S | **done** 2026-08-25, unreleased. Reverses item 138's own decision, confirmed with the user. `generatorIsFlat()` in `config.cpp` is now the single closed set of flat generators, replacing three hardcoded comparisons against `"sent"`: the built-in filter, the reader that reapplies the mode, and the writer that skips storing what the generator implies. Those three had to agree and nothing made them; a `drafts` entry saved and reloaded would otherwise have come back THREADED while the button was flat. `builtinFilter()` sets `flat` once from the helper rather than in a branch, so the set cannot drift from the labels |
| 160 | The composer never says a draft was autosaved | feedback | S | **done** 2026-08-25, unreleased. A status bar on the composer: the age line left, the `○ unsaved content` cue beside it. **The fix is a funnel, not a label.** `m_dirty` had SEVEN writers and four of them clear it, only two of which are a save, so a cue hung off the save path silently missed the constructor and the send; `setDirty()` is the one writer now and refreshes both cues plus `setWindowModified()`. Presentation was **reworked after the user looked at it**: it first reused item 151's yellow ribbon treatment, which reads as a misplaced widget on a bare status label, and the cue sat in the permanent (right-hand) tray. Two defects found by probing rather than by reading, see the section |
| 161 | The composer has no menu bar | discoverability | S | **done** 2026-08-25, unreleased. File / Edit / Format, to the user's own chosen scope. **Save draft (`Ctrl+S`) is the only NEW action**; everything else is gathered, and the menus show the toolbar's own `QAction` objects rather than copies, per item 140's rule. Two needed hand-building: the HTML toggle is a `QToolButton` and cannot go in a menu, so a checkable twin mirrors it BOTH ways; and the signature entry takes the switch's own `QMenu` pointer, since that menu is rebuilt when the signatures change and copied entries would go stale. Edit's entries follow the editor's own `undoAvailable`/`copyAvailable`. `theMenuBarReachesEveryComposerAction()` is item 132's rule applied to the composer, walking the real menu bar and finding actions by `findChildren`, so a future action added to the toolbar and forgotten in the menus fails without touching the test |
| 162 | Delete fails while a sync is renaming the file underneath it | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix and notmuch keeps the pre-`U=` name until that sync's `notmuch new` runs, so `moveMessages` renamed a path that no longer existed and Delete silently did nothing while blaming the destination folder. `moveMessages` now re-resolves by MESSAGE ID when the recorded path is gone: one reindex of that directory, then the filename that exists on disk. Bounded to one retry, so a file genuinely gone still reports. Holding the move during a sync was the other candidate and is NOT the fix: `sendMove` already refuses on notmuch's write lock, but this window sits between mbsync's rename and that sync's `notmuch new`, which touches no lock |
| 163 | The message pane shows a stale path, and the composer forks the draft | defect | S | **done, 2026-08-25.** mbsync renames an uploaded file to add its `,U=<uid>` infix while the model still holds the name the query returned. `MaildirName::resolveRenamed()` returns the path unchanged when it exists, else finds the file in that one directory whose unique stem matches; it refuses an ambiguous match and yields nothing for a genuinely missing file. Wired into all THREE read sites: the pane, Reply/Forward, and the draft reopen. The reopen was the one that cost data, forking a draft into two files with two Message-IDs, both reaching the server |
| 164 | A draft this application saved keeps `inbox` | defect | S | **dropped** 2026-08-27, NOT A DEFECT. The premise was a measurement artifact: its evidence was `notmuch search --output=tags`, which DISPLAYS the union over a thread, and a reply-draft under an arrived message reads `draft inbox unread` while no message carries both. Re-measured at message level: 0 of 12 drafts carry `inbox`, including nine written on or before 2026-08-25. The `unread` half was real and is item 172 |
| 165 | A draft gets a new Message-ID on every autosave | defect | S | **done 2026-09-06**, unreleased. A draft keeps one identity across its revisions and across a reopen, so a save replaces the message rather than adding one; the sent copy still mints its own, at the user's decision. `OutgoingMessage::messageId` empty means "generate", so the send path is unchanged by construction. The four revisions already on the server were deleted by hand, not by code. Section in the closed file |
| 166 | Mail you send to your own other account loses `inbox` | defect | S | **done 2026-08-25**, unreleased. `sent_only()` keeps a message only when EVERY file is inside a sent folder, which is what the carve-out's docstring already claimed. No query can express it, measured; the root comes from `database.mail_root`, with a split-index fixture the ordinary layout cannot provide. Verified read-only against the live index: 780 of 807 still stripped, 27 spared, no arrival affected |
| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together . **Its rule is now incomplete, see item 178** (2026-08-28): the check still judges a row on `firstMessagePath`, which item 177 stopped being a conversation row's identity, so a conversation whose messages disagree about the trash answers on one of them |
| 169 | A card shows the account only as a bar, with no fade and no avatar | presentation | M | **done** 2026-08-26, unreleased, on `card-avatars`, merged fast-forward. Both halves: a `QLinearGradient` from the account colour to the pane's base across the card, and a squircle avatar with initials, given a rect in `CardLayout` so the geometry is asserted without a painter. **Hand-testing found four defects**, all fixed in 9ae43f9: `Avatar::initialsFor()` normalises the display name first (drops the angle-addr, takes the first comma-separated author, unwraps quotes, treats a bare address as no name, requires a word to carry a letter or digit); the two-tone gradient axis spans the DIAMETER rather than a radius, which was letting one hue fill the whole face; the account fade runs right to left, anchored opaque at the card's right edge; and a flat view hashes `ThreadSummary::firstMessageRecipient` rather than the user's own address. The vCard half stays blocked on item 72 |
| 170 | A row that stops matching the view only leaves it on the Delete path | defect | S | **done 2026-08-28**, unreleased. `MainWindow::syncViewMembership()` is the guard, moved off the move path and called from all three funnels (message, thread, move). It does the INVERSE too: a write that adds the view's tag back refreshes, since the model cannot insert a row for a thread the query never returned, and without it an undone mark-read stayed invisible in the view it was undone in. Found a second defect while testing: `ThreadListModel::applyTagChange()` never updated the ROOT message's own tags, which a thread row's card draws in preference to the summary since item 110, so an archived thread both kept drawing `inbox` and was judged to still match |
| 171 | A forwarded HTML message reaches the recipient as plain text | defect | M | **done** 2026-08-27, unreleased. Design in `specs/2026-08-27-forward-html-design.md`. The forward sends ONE part chosen by the Send-as-HTML toggle, with the original shown in a read-only pane beside the editor, and remote content stripped by default with a per-forward opt-out. Hand-tested 2026-08-28. Four parts: `HtmlSanitiser` (an ALLOW-LIST, unlike `namespaceCids()`, because a missed strip is a beacon where a missed rewrite is a broken image), a text fallback for the ~9% of mail with no plain part, the MIME nesting, and the composer control |
| 172 | A draft this application writes is tagged `unread` | defect | XS | **done** 2026-08-27, unreleased. `DraftStore::write()` was called with `"D"`, and `maildir.synchronize_flags` makes notmuch tag anything without `S` as `unread`. Self-healing on the next sync of that folder, which is what made it look intermittent |
| 173 | The composer is a plain-text editor, not WYSIWYG | v2 | L | open, 2026-08-27, **asked for by the user** while hand-testing 171. This is a GUI mail client and should edit rich text the way one does: the forwarded original, and the user's own formatting, visible and editable in place. Supersedes the preview 171 shipped as a middle ground, and **subsumes item 133** (markdown syntax highlighting), which is the same want answered cheaply. See the entry: the draft format and the markdown-as-source-of-truth model both change |
| 176 | Undoing a thread-scoped action applies its inverse to messages it never changed | defect | S | **done 2026-08-28**, unreleased, on `thread-row-identity`. `NotmuchWorker::applyTags()` reads each message's tags before writing and reports only the ids whose tags actually MOVED; a `TagCommand` base carries that effective set for both `ThreadTagCommand` and `MessageTagCommand`, which had the same defect on a multi-row selection. `tagsApplied` does NOT fire on an empty effective list, since an empty change would push an undo entry whose inverse adds a tag no message ever carried, the same bug one step later. `sendThreadTagChange` gained `onlyMessageIds` so it keeps its thread-scoped REPAINT while restricting the WRITE: the card that changed on screen and the messages that changed on disk are different sets on purpose. **The spec's own plan said item 177 would make a thread undo honest and shrink this to the multi-row case; that was wrong and is corrected in the spec**, an undo inverts an EFFECT, not a scope |
| 177 | A thread row means both a message and a conversation, and neither consistently | design | L | **done 2026-08-28**, unreleased, on `thread-row-identity`, eleven commits. Spec: `specs/2026-08-28-thread-row-identity-design.md`. `ThreadListModel::isConversationRow()` is the single predicate and `scopeForSelection()` the single resolver, replacing the `scopeFor()`/`messageScopeFor()` pair that made the CALLER choose. A summary with `totalCount == 1` is unchanged. **Reverses items 108, 110 and 111**, and the user confirmed they are happy to lose the two-tier chips; the `*_thread` submenu and its five action names are deleted with an `### Upgrading` note. Item 112's hiding rule is reversed too: with the absolute entries gone, hiding the toggle on a mixed selection leaves no way to act, so it is a catch-all and the write direction moves with the label. Membership is the union, with two user decisions kept (never evict the current row; an asked-for write evicts at once, an automatic one defers) and one documented lag (a long thread's summary is not updated by a message write, so reading its last unread message waits for the next query). Dashboard from a `ThreadDigest` read by its own worker walk. Two traps found while building: a `QStackedWidget` takes the LARGEST minimum width of its pages and the hidden dashboard was raising the pane's minimum to 395px over MainWindow's 300px floor, caught by an existing resize test; and the pane now holds two `TagStrip`s, so both are named |
| 178 | Delete and Restore judge a conversation on one message | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`. `ThreadDigest` carries every message's path, collected by the walk it already makes, so the predicate tests the whole conversation. Known for the SINGLE selected conversation row the digest was requested for; any other selection falls back to the summary's one path, which is the pre-177 answer, deliberately left no worse rather than given a second differently-wrong rule. Section in the closed file |
| 174 | An external sync's outcome can only be inferred, and never names what it carried | defect | S | **done 2026-08-29**, unreleased. The premise was corrected first: a bare `notmuch new` must NOT clear the count, since the edits are in the index but not on the server, and the item's own proposal to watch `notmuch_database_get_revision()` was rejected for that reason. `mailsync.sh` writes a JSON status file instead, naming the channels a run carried; the external path now narrows its clear the way the local one always has, and a `skipped` run clears nothing. Log fallback kept. Section in the closed file |
| 175 | The send countdown says Undo, and cannot be skipped | presentation | XS | open, 2026-08-28, from the notes. Two changes in one control: the button reads Abort, and a second button sends immediately rather than waiting the countdown out |
| 179 | Undo is one level deep in practice, and there is no Redo | workflow | ? | open, 2026-08-29, from the notes. The `QUndoStack` is real and multi-level; what is missing is a `redo` action (absent from `knownActions()`, never called) and an answer to the stack being CLEARED on every new query (`mainwindow.cpp:3458`), which is what makes a deep stack behave like a shallow one. The clear has a correct reason and cannot simply be removed. Redo re-applies a write to real mail, so item 176's rule binds it too |
| 180 | The repaint rules are discovered one hole at a time | maintenance | S-L | open, 2026-08-29, from the notes, and a QUESTION rather than a defect. Items 105, 107, 109, 110 and 170 are each one hole in the same surface, all found by hand. Three mechanisms (optimistic repaint, `syncViewMembership()`, revert) agree by documentation rather than by code. Cheapest answer is one invariant test, not a rewrite; the user decides which, and that decides the size |
| 181 | The thread dashboard does not follow a write to the conversation it shows | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`, from the notes. The dashboard draws a `ThreadDigest` built by the worker from the INDEX, which arrived only on selection, so a tag write moved the model and the card and left the pane reporting the count the conversation had when it was opened. Reachable from the dashboard's OWN Mark all read button. Re-requested from `onTagsApplied()`, where the write is confirmed: queued beside the write it races it and answers from the state before it, which is how the first fix passed review and failed the test. Section in the closed file |
| 182 | An edit made during a sync is announced twice and never says it is waiting | defect | XS | **done 2026-08-29**, unreleased, on `thread-row-identity`, found by hand. The hold branches set a deliberately NON-transient label; all three callers overwrote it a line later with the bare action, so the user was told the write had landed and then told again when it really did. `announceAction()` adds the wait to the action rather than replacing it, since that announcement is what stands in for the confirmation dialog this project rules out. Section in the closed file |
| 183 | `undoingAMarkReadRestoresOnlyWhatWasUnread` fails about 1 run in 9 under the full suite | testing | ? | open, 2026-08-29, measured. Item 176's regression test, which guards the undo that rewrote 44 messages of real mail. Nine runs on master: 4 standalone, 3 under `ctest -R mainwindow`, 3 under the FULL parallel suite, and the single failure was in the last group. Not a regression, the base commit behaves the same. Probably the same root cause as item 136 and worth solving with it |
| 184 | New mail waits up to ten minutes, because sync is a fixed cron tick | workflow | ? | **done 2026-09-13**, outside this repo and confirmed running on this machine (PID watching, `~/bin/mail-watcher.sh --config ~/.config/mail-watcher/config.ini`). Built as `mail-watcher`, its own repository at `~/Programming/GIT/mail-watcher`, designed in its own `docs/superpowers/specs/2026-09-13-mail-watcher-design.md`. It took the shape this entry argued for and settled the three decisions it listed: a watcher of ours rather than a third-party daemon, so no new SlackBuild; one Python file, standard library only; one thread per watched folder with its own reconnect, one trigger loop owning every `mailsync.sh` invocation with a debounce, default-watch with an explicit exclude list. Both constraints held: the cron tick stays as a backstop, and `/tmp/mbsync.lock` is still the shared mutex. Nothing in `src/` changed, which is the point, the no-network-protocol rule is intact. Section in the closed file. Original entry: open, 2026-08-29, from the user: the 10 minute tick "has always bothered me", and it is already a compromise down from 30. Outgoing edits are immediate (`auto_sync_delay_ms`), so this is the INCOMING half only. Polling faster is not the answer; IMAP IDLE is, and it lives in a watcher that triggers `mailsync.sh`, NOT in qtmaildir, which does no network protocol work. Needs decisions first: which watcher, whether it packages on Slackware, and what the server supports. **Blocked on 174**, whose status file is the reporting channel this needs anyway |
| 185 | The message-pane bar offers Reply and Forward on a trashed message | presentation | S | **done 2026-08-29**, unreleased, with 186. The bar has a third branch keyed on the SELECTION being in a trash folder, the same predicate the menus use: Restore, Delete permanently and Empty trash replace the reply pair, and Restore alone is tinted. Added `purge`, the selection-scoped sibling of `empty_trash`, which inherits both its safeguards. Refilled from the digest as well as from the selection, since a conversation's trash-ness is not known until every path is reported. Section in the closed file. Original entry: `MainWindow::refreshMessageBarActions()` (`mainwindow.cpp:2311`) swaps the bar's message half for a DRAFT and for nothing else, so the trash view shows the two actions that make least sense there. The notes ask for Restore and Delete permanently in their place, and for Delete to move here from the main toolbar (item 186). The visibility rules already exist in `refreshTrashActions()`; what is missing is the bar consulting them |
| 186 | Delete sits on the main toolbar rather than beside Reply and Forward | presentation | XS | **done 2026-08-29**, unreleased, with 185. Moved to the message bar's ordinary branch; still in the Message and context menus. Section in the closed file. Original entry: `toolBar->addAction(... "delete")` at `mainwindow.cpp:2251`. The user places it with the message actions, so this rides with item 185 rather than being done alone: moving it before the bar is trash-aware leaves Delete in a bar that still offers Reply on trashed mail |
| 187 | There is no Spam view beside Trash | workflow | M | open, **specified 2026-09-10** in `specs/2026-09-10-spam-view-design.md`, which covers 190 and 195 too; read that rather than this row. Grew again: the user added Empty Spam (a MOVE to the trash, per account) and the `deleted-from:` -> `moved-from:` rename. 2026-08-29, from the notes; **shape settled 2026-08-29** after two corrections and three decisions from the user. Spam works like Trash: path-based, a mandatory per-account `spam` key, and Mark spam MOVES the file. Every account can now reach a spam folder, the three Gmail ones having gained `[Gmail]/Spam` in `.mbsyncrc` this session. Grew from S to M: the move path, the origin tag and a cleanup pass are three parts, and it changes what an existing action does. See the entry |
| 188 | Does Empty trash respect the account selector? | question | XS | **answered 2026-08-29** by reading the code, no work needed. It does: `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads `m_accountBox->currentData()` and uses `allTrashQuery()` only for All accounts, and the confirmation names which. Recorded so the notes' question has an answer rather than sitting open |
| 189 | The message bar carries only Reply, Forward and Delete | presentation | S | **done 2026-08-29**, unreleased. Star and Archive joined the bar's ordinary branch, Archive leaving the main toolbar as Delete did. `mark_all_read` deliberately did NOT move, at the user's decision: it is the one action that ignores the selection. Item 140's toolbar test listed `archive` as a list-wide action and had to be corrected, which is the classification this item changed. Section in the closed file. Original entry: Asks for Star (`flag`) and Archive on the bar, and raises Mark all read as a question. Two of the three are selection-scoped and fit the bar's rule as it stands; **`mark_all_read` does not**, since it deliberately ignores the selection and acts on every row in the view, which is the one action in the window that does. Needs a decision from the user on that one and on whether Archive LEAVES the main toolbar the way Delete did |
| 190 | Mark spam is not on the message bar, and its icon was never chosen for one | presentation | XS | open, 2026-09-06, from the notes. The bar's ordinary branch carries Reply, Forward, Star, Archive, Delete after item 189 and `spam` is not among them, though it meets the bar's rule (selection-scoped, undoable). Two halves: put it on the bar, and settle the icon, which the note asks to be "a bug, or a skull, or something that signifies bad/evil" and which is `mail-mark-junk` today, chosen for a menu where the label carries the meaning. **Paired with 187**, which changes what the action DOES (moves the file); ordering is the user's call |
| 191 | The Sent view collapses two messages you sent in one conversation into one row | defect | S | **done 2026-09-06**, unreleased, from a hand test. The Sent and Drafts views are flat, but the worker emitted one summary per THREAD and picked a single matched message to stand for it, oldest-first. A conversation replied to twice showed one row, dated by the thread and opening the OLDER message, and the newer one was reachable nowhere. Also a data-safety defect: `firstMessagePath` named the wrong file, so Delete would have moved it. A second half, found by hand once the rows appeared: the sort notmuch applies is a THREAD sort, so both rows took their thread's position and an older reply drew above a newer one. Flat rows are now sorted as one list. Section in the closed file |
| 192 | A sent message does not appear in the Sent view until the next sync | defect | XS | **done 2026-09-06**, unreleased. The sent copy was filed correctly and never announced, so the index did not know it and the Sent view, a path query, could not show it. Measured as 65 files against 64 indexed. One signal to the worker, mirroring what drafts have had since item 158. The open question, whether the view should also refresh, was answered yes by the user on 2026-09-07 and built: `indexChanged()` to `refreshCurrentQuery()`. Section in the closed file |
| 193 | The composer has no headings control | v2 | S | open, 2026-09-08, from the notes: "headers dropdown in the editor, H1 to H6 translating to #, ## ... already supported by the html render". The note is right about the renderer: cmark-gfm parses ATX headings in the core grammar, so `## x` already renders. The gap is composer-side. A heading is a LINE PREFIX, not a wrap, so it cannot go through `applyFormat()`/`MarkdownFormat::wrap()`; it is `quote()`'s shape, and unlike quote it must REPLACE an existing prefix rather than stack one, or a second press gives `## ## x`. That makes it the first formatting control that has to read the line's current state, which is item 135's question arriving early on one control |
| 194 | No abuse reporting from a flagged message | workflow | L, split | open, 2026-09-08, from the notes and **confirmed by the user the same day as a feature they want and will build**. Parse a flagged `.eml`, extract IOCs, resolve abuse contacts via RDAP, generate X-ARF (RFC 5965), fan out to AbuseIPDB/URLhaus/VirusTotal and to abuse desks, backed by MISP via PyMISP. **One gesture here, the engine in a sidecar**: the split is architectural (four outbound protocols, which `src/` does not do) and not a judgement on the feature. qtmaildir's half is a message-bar button that marks spam and offers to report, with a confirmation; it is S and buildable before the sidecar exists. The user is a security consultant filling a phishing database, so the sidecar is the point rather than an accessory. Needs a spec for the sidecar; the qtmaildir half needs only 187/190 settled. Two of the user's constraints are safety properties: redact recipient identifiers before submission, and never fetch remote content during parsing |
| 195 | Mark spam leaves the message unread | defect | XS | open, 2026-09-10, from the notes ("marking a message as spam without reading it doesn't remove the unread tag"). Verified: the action at `mainwindow.cpp:1788` adds `spam` and removes `inbox`, and names no other tag, so an unmarked message keeps `unread` and every unread count keeps counting it. Small on its own; it touches the same action item 187 rewrites into a move, so doing it inside 187 costs nothing and doing it alone is a two-word change to one `tagSelected()` call. One question for the user: whether marking spam should mark read, or whether the tag should simply not be part of the unread views once 187 makes the view path-based |
| 196 | Spam is never tagged automatically | workflow | ? | open, 2026-09-10, from the notes ("the app should be able to tag spam automatically leveraging intel from abusectl"). Depends on 194's sidecar existing: `~/Programming/GIT/abusectl` is a repo but nothing is on `PATH`, so the intel this would read does not yet have a shape to read. Also unspecified in direction: the natural home is the `post-new` hook rather than `src/`, since tagging at sync time is what `assets/hooks/mailrules.py` already does, and a rule sourced from an external database is a format question for both readers (see "Changing the rule format"). Ask the user what abusectl would expose before designing anything |
| 198 | The unsynced-changes list never says which account a message belongs to | presentation | S | open, 2026-09-13, from the notes ("when clicking on the bottom right status bar, there's no way to discriminate what message belongs to what account"). The click opens `PendingChangesDialog` (item 119). Verified: `PendingChangeRow` (`pendingchangesdialog.h:32-50`) carries subject, action, `startsMessage` and `messageCount` and no account, so a list of five subjects across five accounts reads as one undifferentiated run. The data is reachable rather than missing: `accountForMessagePath()` (`mainwindow.cpp:6219`) resolves an account from a path, and `resolvePendingSubjects()` already walks every id in the worker and answers positionally, so the account is one more field on an existing round trip. One asymmetry to settle first: a held THREAD edit carries a thread id rather than a message id (`pendingChangeSnapshot()`, `mainwindow.cpp:5699`), and a thread can in principle span accounts, so the thread rows need a rule of their own rather than the message answer |
| 199 | The window chrome uses the system icon theme, and the user wants a shipped set | presentation | M-L | open, 2026-09-13, from the notes ("we should ship our own icons, color themeable to be consistent in every theme a user may implement, since icons are a brand identity"). This deliberately REVERSES item 70, which drew the split as "panes are ours, chrome is the system's" and shipped `Marks` for the panes only; the note asks for the other half too, so it is a decision to revisit rather than a defect. Verified: the `themeIcons` table at `mainwindow.cpp:2211` and six `QIcon::fromTheme` sites in `composewindow.cpp` are every chrome icon, all resolved from the desktop theme. The mechanism already exists and is proven, `Marks::pixmap` compositing `fill="currentColor"` with `CompositionMode_SourceIn` so one asset serves a light and a dark palette, and `src/marks.h` records why it is compiled-in string literals rather than a `.qrc`. The size is the ARTWORK, not the code: item 70's six marks are shipped, this is roughly forty actions, each needing a drawing. Needs a decision from the user on scope before it can be sized honestly, and on whether the system theme stays as a fallback for an action with no shipped icon |
| 200 | qtmaildir cannot be launched at a given account, thread or message | workflow | M | open, **specified 2026-09-13** in `specs/2026-09-13-cli-selectors-design.md`; read that rather than this row. The user settled three things: a second launch STEERS the running window over a `QLocalServer` rather than opening a second one, the selectors are `--account`/`--thread`/`--message` (`--query` dropped as the one with no caller), and a selector matching nothing opens the window normally and says so in the status bar. The design shrank on one side and grew on the other: `recoverStaleThread()` already runs `thread:<id>` with a deferred selection and is reused as a third caller, so the selectors are the small half, while the socket (connect-first ordering, stale-socket recovery, a degrade path when no socket is possible) is the real work and adds `Qt6::Network` to the component list. Original entry: open, 2026-09-13, from the notes ("the program should accept cli parameters like `--account` or `--thread`/`--message`, so that another app can launch qtmaildir opening that account's inbox or a certain message/thread"). Verified: `main.cpp:38-66` hand-rolls a `strcmp` loop over `argv` for `--version` and `--help` only, both answering before `QApplication` exists, which is deliberate and documented. Parsing is the small half and `QCommandLineParser` covers it; the item is bigger than it looks for two reasons. There is NO single-instance mechanism (no `QLocalServer` anywhere in `src/`), so a second launch opens a second window against the same notmuch database rather than steering the running one, and notmuch permits only one open handle per process. And the selector has to reach a query the startup path does not currently take, since `--thread` names a row that may not be in the configured startup view at all. Needs a decision from the user first: whether a second launch should focus the running window (which is the useful behaviour for "another app launches qtmaildir" and is the whole cost of the item) or simply start with a different query |
| 197 | No way to say a message is not spam | workflow | S | open, 2026-09-10, split out of the 187 design at the user's decision rather than built into it. Restore already covers what qtmaildir moved: a message it marked carries `moved-from:` and goes back where it came from. The gap is mail the PROVIDER's filter caught, which was never in an inbox and carries no origin tag, so "not spam" has no recorded destination to return it to. Needs two answers before it can be planned: where such a message goes (the account's inbox is the obvious guess and is a guess), and whether anything should tell the provider its filter was wrong, which is network work this application does not do and would belong in a sidecar like item 194's. No seam is needed in the meantime: `sendMove()` already takes any destination and any tags |
| 201 | A message in the Spam view cannot be un-spammed, even one qtmaildir put there | defect | S | open, 2026-09-14, found while testing the `spam-view` branch. Corrects item 197's claim that Restore already covers what qtmaildir moved: the CAPABILITY does (`restoreSelectedFromTrash()` reads the origin from the database), but no SURFACE offers it for spam, so only Ctrl+Z immediately after the move reverses it. The user asked for it to be built on `spam-view` before that branch merges. See the section |
Sizes are rough: XS under an hour, S a sitting, M a session.
---
## 21. Default shortcuts are not sensible enough
**Observed (user, 2026-08-04):** "improve the default shortcuts to some sensed
defaults."
**Unspecified in detail**, so ask which bindings feel wrong before proposing a
table. What is worth recording is the history, because the defaults have
already moved once and the reasons still constrain any second pass.
**Where the current defaults came from.** 0.1.0 used bare letters. They were
replaced in the 0.2.0 menu work for two reasons that have not gone away: a
single letter cannot be a menu accelerator without claiming that letter
window-wide, and a bare capital such as `N` parses to an unshifted `Key_N`,
which no keystroke emits, so `toggle_unread`, `flag` and `sync` were dead keys
that appeared to be bound. See `KeyMap::defaultBindings()` and
`normalizeSequence()`.
**Constraints on any new default.**
- **Do not test reachability with synthetic input.** `QTest::keyClick()` does
not reproduce a keyboard layout: it reported `Ctrl++` as dead when it is
exactly what the `+` key emits on the user's Italian layout. Verify against
the real keyboard, as `CLAUDE.md` records.
- Every binding is overridable in `[keys]`, so this is about what a fresh
install feels like, not about what is possible.
- `Return` is a special case already resolved: it belongs to `open_thread` but
the query bar claims it back while focused, so a proposal that moves it must
not resurrect that bug.
**The user is drafting the table, 2026-08-23.** It lives in their own notes as
`qtmaildir shortcuts and menu structure.md`, linked from the note this item
came from, and it is the specification this item was waiting for: a row per
action with the current binding, the proposed one, and an explicit "no
shortcut" column for the actions that should have none. **Read it before
starting, and do not propose a table of your own.** It is unfinished in two
known places, so it is a starting point rather than a finished spec:
- **The menu-structure half is one line long** ("File should hold Save
message") and is where the second half of this item's work is specified.
- **The compose actions are absent from it**, because it predates them being
usable by hand. The user's position as of 2026-08-23, stated but not yet
written into their table: `Ctrl+Return` for Send is **kept**, which closes
that open question from item 123 task 11; and major actions should not go
three modifiers deep, so Reply becomes `Ctrl+R`, Reply all `Ctrl+Shift+R`,
and Forward `Ctrl+F`.
**Two collisions that proposal creates, both to settle before building.**
`Ctrl+R` is `restore` today, and the draft's own row for it says "ok if not
needed for something else" — it now is, so Restore needs a new binding or
none. And `Ctrl+F` is Find in most applications; the draft frees it by moving
Find to `/`, so the two are coupled, and if `/` does not survive review then
Forward loses its binding with it.
**`/` for Find needs an event filter, not a shortcut.** Qt withholds only
plain LETTERS from editable widgets, so a `/` registered as a `QAction`
shortcut is dispatched before the query bar, the tag dialog and the composer's
editor ever see it, and a user could not type a path or a URL in any of them.
This is the same trap `CLAUDE.md` records for arrow keys, and `Return` is the
worked example of the fix: claim it in `MainWindow::eventFilter` by accepting
the `ShortcutOverride`, narrowly, for the one widget that needs it.
**Dropping a shortcut is not dropping the action.** The draft marks the five
`*_thread` actions (item 108) for removal, and the user confirmed on
2026-08-23 that this means their SHORTCUTS only. The menu entries must stay:
`everyActionIsReachableFromAMenu()` is a required rule, while item 132 made
the shortcut itself optional, so an action with no binding is now ordinary and
prints as `(unbound)` in the shortcut reference.
## 40. No live filter over the current view
**Observed (user, 2026-08-05):** "search in current view", spelled out as two
things: "a light filter applied live on the current view", and "a search bar
appearing as soon as we type while no entry box is focused".
**Cause (verified in code):** the only search is the query bar, which runs a
notmuch query and replaces the result set. There is no client-side filtering
of an existing result: no `QSortFilterProxyModel` anywhere in `src/`, and
`ThreadListModel` has no filter of its own. Narrowing the current view therefore
means writing a new notmuch query and losing the view.
**Approach.** Distinct from the query bar, and the distinction is the point: this
filters rows already fetched, without touching notmuch.
- A filter over the model's loaded rows, matching subject and from, case
insensitively. No worker round trip.
- A filter strip that appears on the first keystroke while no entry box has
focus, and disappears on Escape, restoring the full result set.
**Constraints.**
- **Type-to-filter competes with the plain-letter shortcuts.** Item 3's outcome
records that a plain-letter `QAction` shortcut is suppressed only while an
editable widget has focus, which is exactly the state this feature does not
start in. Any binding that is a bare letter would be swallowed by the filter
strip or would swallow it. Check the current defaults before choosing the
trigger, and prefer appearing only for characters no action claims.
- Escape already blanks the message pane (item 32). If Escape also closes the
filter, decide the precedence explicitly rather than letting whichever handler
runs first win.
- The filter is presentation only: it must not clear the selection, the undo
stack, or the query, and the pending-edit count must not move.
- Interaction with item 39: a filter and a sort over the same rows want the same
proxy. Whichever is built first should leave room for the other.
## 65. No full code review and optimization pass
**Observed (user, from the notes):** "full code review and optimization."
**Cause:** not a defect. The codebase has grown from the 0.1.0 spec through
sixty-odd backlog items, and nothing has gone back over it as a whole.
**Why this cannot be planned from the backlog.** "Review and optimize" names no
symptom, no measurement and no target. There is no reported slowness to chase,
and the one performance property the design does commit to (threads emitted in
batches of 200 so a 10k-thread query paints immediately) already holds. An
optimization pass with no measurement behind it is the kind of work that
produces a large diff and no change a user can notice.
**Narrowed by the user, 2026-08-26.** The notes now name two sub-bullets, and
they are the same piece of work rather than two: "deduplication of
functionalities" and "check for dead code (functionalities superseded by other
additions, rendering them useless now)". So this is a dead-code and duplication
sweep, NOT a performance pass and not a security review. Nothing slow has been
reported, and the translatability audit it might have meant is item 22, already
done.
**What that makes it.** A read of the whole tree looking for a function with a
newer twin and for a path nothing reaches any more. The codebase has precedent
for both: `threadAt(int)` survives beside `threadFor(index)` for one legitimate
caller, `SubjectDelegate` was deleted outright at item 53, and item 132 deleted
a whole test rule that had stopped serving. The output is a LIST first, one
entry per candidate with the evidence that it is dead or duplicated, not a
diff; the user decides what goes.
**Constraints.**
- "Unreachable from the UI" is not the same as dead. Item 16's
double-press-to-undelete branch reads as dead and is not, because stranded
mail reaches it. Every candidate needs the reachability argument written out
before it is cut.
- A test is a caller. Deleting production code with only test callers is
usually right; deleting the test with it needs saying so explicitly.
- The sweep is worth nothing if it is not run against a green suite before and
after, since the whole value is that nothing observable changed.
**Size: still `?` until the list exists.** The sweep that produces the list is
S to M; what it finds is the work.
## 72. No khard/khal integration
**Observed (user, from the notes):** "investigate khard/khal integration (light
PIM, probably worthy after we add send capabilities)."
**Cause:** not a defect. v1 is read-and-organize; there is no address book and no
calendar anywhere in the codebase.
**Why this cannot be planned.** The user's own note places it after send, and
send is v2. What "integration" means is undecided: completing recipients from
khard when composing, showing a sender's card, or acting on an invitation.
Those are three different features.
**Size: `?`, unspecified**, and out of scope until v2 exists. Ask before designing
anything.
## 99. The unread action is labelled "Toggle unread" whichever way it will go
**Observed (user, from the notes):** "the label for 'toggle unread' should be
dynamic: on an 'unread' message it should be 'Mark as read', on a 'read'
message it should be 'Mark as unread'."
**Cause (verified in the code).** `src/mainwindow.cpp:867` registers one static
label, `tr("Toggle &unread")`, and the lambda decides the direction at
invocation time from the current row. The action carries that text in three
places at once: the Message menu (`:1060`), the thread context menu (`:1167`)
and the toolbar (`:1122`, with the `mail-mark-unread` icon). Nothing updates it
when the selection changes.
**Not as simple as reading the current row**, which is why this is S and not XS.
- The action applies to the WHOLE selection and picks one direction from the
current row, so with a mixed selection any label naming a single outcome is
either wrong for some rows or has to describe the rule ("Mark all as read").
- A menu action's text is read when the menu opens, but a TOOLBAR button's text
is on screen continuously, so it has to track `selectionChanged` rather than
being computed at popup time. `currentRowChanged` is the wrong signal for
anything selection-shaped, per `CLAUDE.md`.
- The accelerator is inside the word (`Toggle &unread`). Two different labels
need two accelerators chosen so neither collides in the Message menu, which
already holds "Mark &spam" and "&Important".
- The shortcut list (Help > Keyboard shortcuts) and the config's `[keys]`
section both name the action `toggle_unread`. The action NAME must not change
with the label, or every user's config breaks. Same rule as item 57, which
changed "Flag" to "Important" on screen and left the action and tag alone.
**Approach.** Compute the label from the same state the lambda already uses,
which since item 105 is `MainWindow::everySelectedRowHasTag("unread")`, update
it on `selectionChanged`, and keep a neutral fallback for an empty or mixed
selection. Decide with item 98, which raises the identical question for
"Important".
**Use that helper rather than re-deriving the state**, or the label and the
action can disagree. It already encodes the two things this gets wrong on its
own: a reply answers about its MESSAGE, not its thread, and the answer is over
the whole selection rather than the current row.
**Constraints.** Every label is user-facing and needs `tr()`. Since the strings
are chosen at runtime rather than written once, all of them must exist as
literals `lupdate` can see; a string built by concatenation is not translatable.
`ctest -R translations` is the check.
**Size: S.** Mostly the mixed-selection and toolbar decisions, not the code.
## 113. No way to see a message's HTML source
**Observed (user, 2026-08-17):** reviewing item 100's removals, "view source
could be useful, we might have to implement it."
**This item exists because item 100 removed something it was not asked to.**
The user named Back, Forward, Reload and Save page. `ViewSource` was added to
that list by the agent, on the reasoning that it was "the same kind of thing",
and it is not: the other four have nothing to act on, while view-source has a
real document and a real use, checking what a message actually contains. The
removal is recorded here rather than quietly reverted, because the reasoning
that produced it is the part worth not repeating.
**Cause (verified in code):** restoring Chromium's entry would not work anyway,
which is why this is an implementation item rather than a one-line revert.
`QWebEnginePage::ViewSource` navigates to `view-source:<url>`. The pane's
document is a `data:` URL (`requestinterceptor.cpp:90-105` records that
`setHtml()` navigates to data: and applies the base URL afterwards), and
`MessagePage::acceptNavigationRequest` accepts only a typed main-frame
navigation, so the attempt is refused before the interceptor even sees it.
Restoring the entry would produce a live-looking menu item that does nothing,
which is the same defect item 100 was reported for.
**Approach.** Our own action, not Chromium's: a dialog showing the message's
HTML as plain text. The source is already in hand, since `HtmlBuilder` produced
it and `MimeParser` holds the original part; nothing needs fetching.
**Constraints.**
- **Plain text is a SECURITY property here, not a style.** `CLAUDE.md` states
it for `MessageDetailsDialog`, and it applies with more force to this: the
content is a stranger's markup, and the whole point of the dialog is to show
it uninterpreted. Set `Qt::PlainText` explicitly on whatever displays it; a
`QLabel` guesses under `Qt::AutoText`. A `QPlainTextEdit` cannot render
markup at all and is the obvious choice.
- Decide which source is shown: the message's ORIGINAL HTML part, or the
document `HtmlBuilder` generated around it. They are different, and the
useful one is almost certainly the original, since the wrapper is ours and
known. Say which in the dialog rather than leaving the user to guess.
- A message with no HTML part needs an answer that is not an empty window.
`messageview.cpp:933` already has the string for this case.
- Reachable from the body context menu, where the removed entry was, so the
gesture the user reached for keeps working.
**Size: S.**
## 114. Save image is offered on every image and does nothing
**Observed (user, 2026-08-17):** right-clicking an image in the message pane
offers "save image", among other entries item 100 never saw because the test
built a menu by hand and no real image was ever clicked.
**Cause (verified in code):** there is no download handling anywhere in the
tree. `grep -rn "downloadRequested\|DownloadRequest" src/` returns nothing, so
Chromium emits the request and no handler answers it. The entry is present,
looks live, and silently does nothing, which is the same class of defect as
item 100 itself.
**Approach, and the security question it raised was resolved by the user.**
The first proposal was to scope this to `cid:` parts and refuse remote images,
on the grounds that saving a remote image means a fetch triggered from a
message. **The user pointed out that this is wrong**, and it is: once remote
content has been granted and loaded, the bytes are already fetched and cached.
Saving them is a local copy, not a new request, and blocking it adds no
security while making the entry useless. The reasoning applied to *fetching*,
which has already happened by the time the entry is reachable.
The real constraint is the neighbouring one: **the save must not itself cause a
fetch.** An image that was never loaded, because it is remote and not granted,
has no bytes to save, and the entry should be unavailable rather than reaching
for the network to satisfy it.
- Connect `QWebEngineProfile::downloadRequested` and let Chromium write the
file, with the directory chosen through `QFileDialog` as the attachment save
already does.
- The rejected alternative: fetching the bytes ourselves to reuse
`Attachment::saveTo()`. That is a second network request originating from a
message, which is exactly what the interceptor exists to prevent, and it
would bypass the per-render remote grant. `cid:` images would map onto that
path cleanly and remote ones cannot, which is why the uniform route wins.
**Constraints.**
- **The path checks are not optional and are already written.** `CLAUDE.md`'s
web-view notes: reduce to basename, strip separators, resolve against the
chosen directory, refuse anything escaping it, and compare resolved paths as
paths rather than with `startsWith`. A filename suggested by a download
request is untrusted input in exactly the way an attachment filename is.
- Do not let this become a second general download route. It saves an image the
user right-clicked, nothing else; `SavePage` stays removed.
- No overwriting. `saveWithoutOverwriting` exists because a silent overwrite
lost six of sixteen files while reporting every one as saved.
- Report the outcome through `statusMessage`, as every other save does. A save
with no feedback is the failure item 13 was about.
**Size: S.**
**Re-confirmed by hand on 2026-08-20**, after items 126 and 127 shipped: the
user right-clicked an image whose remote content had already been loaded and
reported "Save Image but does nothing". Still this item, still unfixed, and the
circumstances sharpen two things.
`m_allowRemote` is a live flag on the shared interceptor
(`src/requestinterceptor.h:56`), granted by `loadRemoteContent()` for the
displayed message and cleared by the next `showThread()`. So a download handler
would be subject to whatever the flag says AT THE MOMENT OF THE CLICK, not at
render time. In the reported case the grant is still live, so a naive handler
would appear to work perfectly, which is exactly the trap: the same code fails
for a `cid:` image after the user moves on, and succeeds for a remote one only
while the grant happens to stand. **Test both against a message whose grant has
been cleared**, or the implementation is only tested in its easy state.
The second is a corollary of item 127's decision below. The handler is
per-PROFILE, so connecting `downloadRequested` lights up every download entry
Chromium offers at once, including the Save link this pane deliberately removed.
The entry being gone from the menu is not the same as the capability being
absent: a page can still originate a download by other means. Whatever answers
`downloadRequested` must decide per request, not merely exist.
**Save LINK is no longer part of this item** (2026-08-20, item 127). It was
deferred here on the grounds that both are inert for want of a
`downloadRequested` handler, which is true and beside the point: they are not
the same question.
Save image is content the message already carries, and making it work is what
this item is about. Save link fetches a REMOTE URL chosen by the sender,
through the pane's profile, which is the one profile in the application that
must never fetch remote content. It is removed from the menu rather than
implemented, and `theLinkMenuDropsTheOpenInWindowActions` asserts its absence.
**That assertion constrains this item.** A `downloadRequested` handler added to
make Save image work must not make Save link reachable again. The test fails if
it does, which is the point: the handler is per-profile, so the natural
implementation would light up both entries at once.
## 121. The thread list shows nothing while a query is running
**Observed (user, from the notes):** "can we show a spinner in the left panel
while 'Searching' is going? Especially at first run, the loading wait is several
seconds, and the status bar starts updating 'Searching N threads' after the
first have already appeared. Before that the program seems broken."
**This is the half of item 74 that was never built**, and the note is precise
about which half. Item 74 closed on 2026-08-15 having fixed the status bar,
which used to set "Searching..." once and hold it for the whole walk. The count
the note describes is that fix working as designed: it is written from
`m_model->rowCount()` in `onThreadsReady`, so by construction it cannot report
anything before the first batch has landed.
**Cause (verified in the code).** `MainWindow::runQuery` clears the model and
sets the status text (`src/mainwindow.cpp:2414`), and nothing else in the view
changes. The thread list is then an empty `QTreeView` until `appendBatch` runs
on the first batch, so **a query in progress and a query that matched nothing
render identically**. There is no busy state on the view at all.
**The gap is measured, and item 74's numbers understate it badly.** Re-measured
on 2026-08-20 against the user's real inbox, seven minutes after boot, with the
index verifiably unread (0.0% of 1037 MB resident). Item 74's figures came from
`posix_fadvise(POSIX_FADV_DONTNEED)` eviction, which does not reproduce a real
cold boot on this hardware:
| phase | item 74, 2026-08-11 | measured cold, 2026-08-20 | warm |
|---|---|---|---|
| `search_threads` returns | 411 ms | **673 ms** | 2 ms |
| first batch of 200 rows | 642 ms | **2008 ms** | 12 ms |
| walk complete | 5714 ms | **38618 ms** | 154 ms |
| threads | 4444 | 4628 | 4628 |
So the list is blank for **two seconds**, and keeps growing for **thirty-eight**,
on 4% more mail. The user's note said "several seconds" and the note was right.
**The cause is the storage, not the code**, and that is the reason to BUILD
this rather than to skip it. `/data` was `/dev/sda1`, a 7200rpm platter
(`rotational: 1`); warm, the identical walk is 154 ms, a 250x difference.
**The developer's own index moved to NVMe on 2026-08-20** (item 124 was its
prerequisite), which took the cold figures to 12 ms / 50 ms / 668 ms and makes
this invisible *on that machine*. That is precisely why the item stays open. A
mechanical disk is not an exotic configuration, it is the cheap one, and a user
who keeps a large Maildir on spinning rust has nowhere to migrate to. The
measurements above are now the best evidence this project has for what such a
user sees on every cold start, and they were taken on real mail rather than
simulated:
| storage | first rows | complete walk |
|---|---|---|
| 7200rpm platter, cold | 2008 ms | 38618 ms |
| NVMe, cold | 50 ms | 668 ms |
Fixing one developer's hardware is not fixing the application. The indicator is
what makes a slow query legible on any disk, and the slower the disk the more it
matters.
**Approach.** A busy state on the left pane between `runQuery` and the first
`onThreadsReady`, cleared by whichever of the first batch or `queryFinished`
arrives first. The empty-result case must be distinguishable from it: when
`queryFinished` reports zero, the pane should say so rather than returning to a
blank list, which is the same ambiguity one step later.
The likely shape is an overlay or a placeholder row rather than a literal
spinner widget, but that is a design question for the user, not a decision to
take here. A spinner also has to be animated by the UI thread, which is free
here since the work is on the worker, but that is worth stating because it is
the usual reason a spinner does not spin.
**Constraints.**
- **A background refresh must stay silent.** `onThreadsReady` returns early on
the refresh branch and `onQueryFinished` does the same, deliberately, so a
sync-driven refresh does not flicker the status bar. A busy indicator that
ignored that guard would make every cron sync flash the list. That silence is
already a test, and it should cover this too.
- **Item 74's decision not to address the cold cost was taken on wrong
numbers**, and its conclusion still holds for a different reason. It judged a
5.7 s wait not worth prefaulting 1.1 GB; the real figure was 38.6 s. Do not
reopen prefaulting: it trades a large fixed cost at every startup against a
wait that only some users pay, and it is worse on exactly the low-memory
machines most likely to have a slow disk.
- **Do not treat "move the index to an SSD" as this item's fix.** It is the
right advice for a user who has an SSD, and it is documented, but it is
hardware guidance rather than a change to the application. This item must
stand on its own for a user with one mechanical disk and no migration
available.
- Nothing about the query timing may change.
**Size: S.**
## 122. The README documents a version of the app that no longer exists
**Observed (user, from the notes):** "documentation needs updating, EG the
README.md reports various things not up-to-date anymore."
**Cause (verified).** `README.md` was last touched on 2026-08-15 by b405e32,
which moved the SlackBuild out to the `my-slackbuilds` repo. Everything released
since then is absent from it. Releases 0.19.0 through 0.26.1 all landed after
that commit.
**Measured, by grepping both documents for the same terms:**
| term | README | CHANGELOG |
|---|---|---|
| `trash` | 0 | 14 |
| `restore` | 0 | 7 |
| `Select all` | 0 | 3 |
| `deleted-from` | 0 | 0 |
**One of these is worse than stale documentation.** Item 103 made a per-account
`trash` key MANDATORY: an account without one produces a config warning, and
Delete cannot work. The README is the only place a user reads about configuring
an account, and it does not mention the key at all. So the documented config
produces a warning against the current binary, and the feature that needs it is
undocumented. The `deleted-from:<folder>` tag is likewise invisible, and a user
who sees it on a message has nowhere to look it up.
**Approach.** An audit against the changelog rather than a rewrite: walk the
sections from 0.19.0 forward and check each user-visible change for a README
home. The config section and the keyboard-shortcut table are the two most
likely to have drifted, since both enumerate things that have been added to.
**Constraints.**
- **The changelog is the evidence, not memory.** Every entry since b405e32 is
written down; work from it.
- **`### Upgrading` sections are the priority.** They exist precisely because a
user's config or habits had to change, and those are the paragraphs whose
absence from the README costs the user a broken setup rather than a moment of
confusion.
- The "Development Approach" section at the bottom is required by the user's
global preference and must survive any edit.
- No personal details, per the same preference: account names in examples stay
generic.
**Size: M.** The audit is most of it; the writing is small once the list exists.
## 123. Sending mail is not designed
**Specified 2026-08-20.** Read
`docs/superpowers/specs/2026-08-20-compose-and-send-design.md` instead of this
section. Brainstormed with the user on branch `compose-and-send`; no code
written, which is what the note's `#plan-only` asked for.
**The three constraints a reader needs before opening the spec.**
- **There is no MTA on this machine**, measured 2026-08-20. `msmtp` and
`sendmail` are both absent, and neomutt sends over its own built-in SMTP. So
"an external script on the same model as `mailsync.sh`" had no model to copy.
The design keeps the no-network-protocol rule by making send a **per-account
`send_command`** taking the message on stdin, exactly as `[sync] command`
works. What the user installs behind it is their choice.
- **An account with no `send_command` is receive-only by construction**, which
is how one of the five accounts is meant to work. Reply, reply-all and forward
are disabled on its mail, with a ribbon in the message pane saying why.
- **The body is markdown**, parsed by cmark-gfm (autolink, strikethrough,
tasklist; tables off), sent as `multipart/alternative` or plain text per a
per-message toggle. A hand-written parser for a limited set was rejected
because it would be deleted wholesale the moment the set widened.
**What it blocks and what it opened.** Item 72 (khard/khal) is placed after send
by the user's own note. The brainstorm opened items 128 to 132: an outbox,
inline images, attaching a message to a message, a configurable markdown
dialect, and a review of the every-action-has-a-shortcut rule.
**Size: L.** Four new units, six new actions, a formatting toolbar over the
markdown source (whose shortcuts live in the composer's own scope and do not
touch `KeyMap`), and one new build dependency,
`cmark-gfm`. That dependency is cheap: it ships in stock Slackware
(`cmark-gfm-0.29.0.gfm.13-x86_64-3`, verified 2026-08-20), so it needs a
`pkg_check_modules` line here and **no** `REQUIRES` entry in the SlackBuild,
which lists only non-stock dependencies.
## 135. The formatting toolbar's buttons stack rather than toggle
**Observed (user, 2026-08-21):** pressing Bold a second time on already-bold
text adds another pair of asterisks rather than removing the first, so
`**this**` becomes `****this****`. Quote nests the same way: a second press on
`> one` gives `> > one`. The user asked for both to toggle.
**A toggle was built and reverted the same session**, and the reason matters
more than the code: it was not unwanted, it **conflicts with the spec**, which
was not checked before the work started.
- `2026-08-20-compose-and-send-design.md:236` states there is "deliberately no
live toggle that inserts and removes the quote while editing".
- `:187-190` is the complete statement of the wrap behaviour and describes only
wrapping, with no toggle anywhere.
**Cause.** This is a **spec change, not a defect**, and both sites need
amending before any code is written again.
Underneath sits a real design question the spec answers one way and a toggle
answers the other, which is why the two cannot simply coexist. `:187` preserves
the selection after a wrap **so that a second press applies a SECOND token** to
the same words: bold, then italic, without touching the mouse. A toggle makes
that gesture unreachable, because the second press now removes the first token
instead. **What replaces bold-then-italic is unanswered**, and answering it is
the substance of this item, not the state machine below. Possible directions,
none chosen: a modifier on the second press, a separate un-format action, or
accepting that the chord is lost and reaching nested emphasis by typing.
**Approach.** When it is picked up, the transformation half is already
understood, so the notes below exist to stop it being rediscovered. A toggling
`wrap()` must distinguish three states, and a single "it unwraps" test passes
against most of them being broken:
- **INSIDE** the tokens: `**this**` with `this` selected (2..6). The tokens sit
just outside the selection; the same characters stay selected afterwards.
- **AROUND** them: `**this**` selected whole (0..8). The selection shrinks to
the text that was between them.
- **PARTIALLY overlapping** one: `*this**` (6..13). Neither of the above. It
does not describe a wrapped span, and stripping would have to guess which
half of a token to keep, so wrapping is the predictable answer.
**INSIDE must be checked before AROUND.** On `***this***` both tests match, and
only INSIDE removes the level the user actually asked for.
**A naive adjacency test is wrong, and looks right.** Checking only whether the
characters either side of the selection equal the token means pressing *Italic*
on `**this**` finds a `*` on each side, strips one asterisk per side, and
**un-bolds text the user asked to italicise**. A strip must require the adjacent
RUN of token characters to be the token exactly, or the token plus one other
complete emphasis token: `***` is bold+italic and divisible either way, while a
run of two is one indivisible token whose half is not a token at all. This was
found by writing the italic-on-bold test, not by reading the code.
The quote side is simpler but has one trap: a bare `>` is what the quote path
writes for a blank line, so an unquote that only recognises `"> "` leaves a
stray marker on every blank line in a round trip. Whether a mixed block (some
lines quoted, some not) quotes or unquotes is a decision; quoting it, so one
press makes the block uniform and the next unquotes it, avoids the button doing
two opposite things to two halves of one selection.
**Constraints.** The spec amendment comes first and must resolve the
bold-then-italic question, or the same conflict recurs. `MarkdownFormat` is
painter-free and widget-free, so the whole state machine is unit-testable
without the composer; keep it that way. The toolbar shortcuts belong to the
composer window and do not touch `KeyMap`, so nothing here interacts with item
132. Note that toggling changes what the preserved selection is FOR, so
`wrappingTwiceNestsTheTokensAroundTheSameWords` and
`quotingAnAlreadyQuotedLineNestsIt` in `tests/test_formattoolbar.cpp` both
assert the current spec behaviour and would be replaced rather than extended.
---
## 190. Mark spam is not on the message bar, and its icon was never chosen for one
**Observed (user, from the notes):** "add \"mark as spam\" to the message pane
toolbar. Use a bug as the icon (or a skull, or something that signifies
bad/evil)."
**Cause, verified in the code.** Two independent halves, and neither is a
regression.
The action exists and has since the first toolbar: `addAction("spam", tr("Mark
&spam"), ...)` at `mainwindow.cpp:1768` writes `spam` and removes `inbox`
through `tagSelected()`. It is reachable from the Message menu
(`mainwindow.cpp:2062`) and the thread context menu (`:2223`), and it carries a
shortcut, `Ctrl+Shift+S` (`keymap.cpp:151`). What it has never been on is the
message pane's own bar: `refreshBarActions()` fills the ordinary branch with
exactly `reply`, `forward`, `flag`, `archive`, `delete` (`mainwindow.cpp:2387`),
and item 189 added Star and Archive there without raising spam.
It meets the bar's rule as it stands. The bar carries selection-scoped actions
with an undo behind them, which is why `mark_all_read` was kept off it under
item 189 and why Star and Archive were let on. `spam` is a `tagSelected()` call
like those two, so it qualifies on both counts today.
The icon is the second half and is the same latent wrong choice item 189 found
in `flag`. `{ "spam", "mail-mark-junk" }` (`mainwindow.cpp:2144`) was chosen for
a MENU, where the label carries the meaning and the icon only decorates it. On
an icon-only bar the icon IS the control, which is what made Breeze's
exclamation-mark rendering of `mail-mark-important` a defect rather than a
preference. Whether `mail-mark-junk` reads as "bad/evil" on the user's theme is
a question only the user can answer by looking, and the note suggests it does
not.
**Approach.** Add `spam` to the ordinary branch of `refreshBarActions()`. Order
is a decision, not a detail: the bar reads answer, then file, then destroy, and
spam is a filing act whose destination is hostile, so it belongs with Archive
rather than beside Delete or before Star. For the icon, offer the user the
theme names that exist rather than picking one unseen; a shipped SVG under
`assets/icons/marks/` is the fallback if no theme name reads right, but that is
the panes' convention and the bar is chrome (item 70), so it is a last resort
rather than a first move.
**Constraints.**
- **The trash branch must not gain it.** `everySelectedRowIsInATrashFolder()`
swaps the bar to Restore, Delete permanently and Empty trash (item 185);
marking already-trashed mail as spam is not an act the user asked for, and the
same question item 187 flags applies here from the other side.
- **The icon table forbids duplicates** for any action that can reach the
toolbar, by the test item 140 established. `mail-mark-junk` is unique today
and any replacement must stay so.
- **Item 187 changes what this action does**, from a tag write to a file move
with an origin tag. Doing 190 first puts a button on the bar whose behaviour
then changes underneath it; doing 187 first means the button arrives already
correct. Neither ordering is wrong and the user chooses, but they should not
be built in ignorance of each other.
**Verification.** The bar's contents are a list in one function, so an assertion
on it is measurable and belongs in the same test item 189 corrected. The icon is
a visual judgement and belongs to the user, per the rule in `CLAUDE.md`: hand it
over and let them look.
---
## Deferred, unsized, or split out
Items noted while triaging but not part of the original list. Same numbering
sequence, appended as they arise.
| # | Item | Why here |
|---|------|----------|
| 12 | `HtmlBuilder` CSS is light-theme only | **Done 2026-08-07**, and moved to the main status table. Kept listed here so the split from item 5 stays traceable. |
| 120 | No way to tell a tag applied by a rule from one applied by hand | **Postponed by the user**, and recorded here on 2026-08-19 from their notes ("should the UI allow to discriminate when a message has been tagged by a rule?"). Not plannable as it stands: nothing records the provenance. A rule has an `id` in `~/.config/mailrules/rules.json`, but `mailrules.py` writes only the tags the rule names and keeps no note of which rule wrote them, so the information does not exist to display. Answering it means the HOOK storing something per message, which is a shared-format change across both repos and needs the procedure in `CLAUDE.md`. Ask the user what they would do with the answer before designing that. |
## Adding to this document
Append a row to the status table with the next free number, then a section using
the same shape: **Observed** (what the user saw), **Cause** (the code, with file
and line, verified not assumed), **Approach**, **Constraints**, and
**Verification** where it is not obvious. Do not renumber. Do not delete: mark
`dropped` with a reason.
**When an item closes, move its section to
`2026-08-03-post-0.1.0-usability-closed.md`** and leave the status table row
here with its date and outcome. This is what keeps the file readable, and it is
the step that was missing for seventy items: doing it only once, as item 73 did,
buys a few months and then the problem returns. Move the section on the commit
that closes the item, not in a later cleanup pass. Where the closed section
records a trap that is still true of the code, that trap belongs in `CLAUDE.md`,
which is where it will actually be read.
**A fully specified item goes in its own file under `docs/superpowers/specs/`,
not inline here.** This document is a backlog: its job is to say what is open,
how big it is, and what decides whether it can be picked up. A design that runs
to a hundred lines buries that under itself.
The split is by depth, not by size on the day. An entry stays here while it
records an observation, a cause and an approach. It moves out once it carries
decisions the user made, measured evidence, and constraints that have to be read
before writing code. Items 53 and 63 are the pattern: the entry keeps the
finding and the size, and points at the spec with one line saying to read that
instead. Carry the two or three constraints a reader needs in order to decide
whether to open the spec at all, and leave the rest there.
Name the spec `<date>-<name>-design.md`, and state in its header which backlog
items it resolves, so the numbering stays traceable in both directions.
## 137. A reply to a message that arrived at two accounts can come from the wrong one
**Observed.** A message that exists in more than one maildir, because it was
sent to two of the user's addresses or duplicated across accounts by mbsync,
can open its reply from either account. Which one is picked is arbitrary. The
consequence is visible in the composer's From field, so it is not silent, but
it is only visible to somebody who thinks to look: the reply is otherwise
correct and sendable, and the recipient sees a From the user did not intend.
**Cause, verified in the code.** The disambiguation exists and is unreachable.
`ComposeContextBuilder::accountForReply()` (`src/composecontext.cpp:405`) takes
`messagePaths` as a `QStringList` precisely so it can resolve this case: with
more than one candidate account it prefers the one whose own address appears
among the recipients, which is the reason the copy landed there. Nothing
upstream ever gives it more than one path. `NotmuchWorker::loadMessage()`
(`src/notmuchworker.cpp:573`) builds its `MessageRef` from
`notmuch_message_get_filename()`, the SINGULAR accessor, so `MessageRef` holds
one `filePath` and `MainWindow::openComposerFor()` can only pass a
one-element list. The plural parameter is therefore inert, and the branch that
consumes it is dead code today.
`notmuch_message_get_filenames()`, the plural accessor that would supply the
rest, exists in libnotmuch and is used nowhere in this repository.
**Approach.** Add `QStringList filePaths` to `MessageRef` (`src/types.h:123`)
ALONGSIDE the existing `filePath` rather than replacing it, and populate it in
`loadMessage()` from `notmuch_message_get_filenames()`. `filePath` stays as the
render path, so `MainWindow::renderMessages()` and everything else that opens
one file are untouched; only `openComposerFor()` reads the new field. That
keeps the change to two files plus the one call site.
**Constraints.** The test has to put the same message id in two accounts'
maildirs, which `NotmuchFixture` can do by writing the same `Message-ID` into
two folders before indexing. Assert on the account CHOSEN rather than on a
count of paths: a test that only checks `filePaths.size() == 2` passes against
`accountForReply()` still ignoring them. The recipient-preference branch is
what needs covering, so the two accounts must have different addresses and the
message must be addressed to one of them, or either answer is correct and the
test proves nothing.
## 136. `undoMovesTheMessageBack` fails about one run in six
**Observed.** `test_mainwindow` failed during a full-suite run while item 123
task 10 was in the working tree. The failing function is
`TestMainWindow::undoMovesTheMessageBack`. The run that failed took 70 seconds
against a normal 25, so whatever goes wrong also blocks for a while before
giving up.
**Not caused by item 123.** This was checked rather than assumed, because a
failure appearing during unrelated work is exactly the kind of thing that gets
blamed on the change in front of it. With the branch's work `git stash`ed out,
on a clean tree, it still failed **1 run in 6**. Nothing in `SendDialog`
touches the model, the Maildir, or the undo stack.
**Cause, unverified.** A race around the Maildir file move that Delete
performs and Undo reverses. Whether the race is in the test's wait or in the
production move is exactly what the item has to establish, and that is why the
size is `?` rather than a guess. The two have very different consequences: a
test that waits wrongly is noise, while a move that races is mail landing in
the wrong folder, and CLAUDE.md already records that a wrong folder name from
this code path reaches the mail server.
**Approach.** Reproduce in isolation first, with the suite's own
`QT_QPA_PLATFORM=offscreen` and a loop over `ctest -R mainwindow`, and capture
a failing run's output before theorising. The 70-second duration is the useful
clue: something is waiting on a condition that never arrives rather than
asserting immediately, so find which `QTRY_*` or `qWait` is timing out.
**Constraints.** A fix must not restore the real `/proc/locks` (item 61), and a
flaky test must not be "fixed" by widening its timeout until it passes, which
converts a real race into a slower green. If the race turns out to be in the
production move rather than the test, this stops being a test-hygiene item and
becomes a mail-safety one.
**Measured again 2026-08-24, and the failure is now DETERMINISTIC.** Found
incidentally while building item 152, by an agent that checked rather than
assumed: it built a throwaway worktree at the commit before its own work and
ran `test_mainwindow` there, failing identically. So the failure predates the
signatures work, and the "1 run in 6" framing in this entry's own title is
stale twice over.
The assertion that fails, quoted exactly:
```
'folderHasMessageFile(root + "/acct/inbox/cur", stem) || folderHasMessageFile(root + "/acct/inbox/new", stem)' returned FALSE
```
That is worth more than the flakiness history, because it says WHAT is wrong
rather than how often: after the undo, the message file is in neither `cur`
nor `new` of the account's inbox. The file is not where the restore was
supposed to put it, so the question this item has to answer narrows to where
it went instead. Check the trash folder and the account root before
theorising about a race: a move landing in the wrong folder is the mail-safety
half of the fork above, and it would look exactly like this.
The 70-second duration recorded above fits a `QTRY_*` waiting for a file that
is never going to appear, which is consistent with a wrong destination rather
than a slow one.
---
## 173. The composer is a plain-text editor, not WYSIWYG
**Observed (user, 2026-08-27):** asked for directly while hand-testing item
171. "This is a GUI mail client and should have a wysiwyg editor."
**Where it came from.** Item 171 forwards an HTML message by carrying the
original's markup, and the markup cannot be shown in a `QPlainTextEdit`. The
first build seeded a text quote into the editable buffer and then dropped it
when building the HTML part, so the user could edit a quote whose edits were
silently discarded. The user's objection is the right one and is more general
than that bug: **what the composer shows should be what gets sent.**
171 shipped the middle ground, a read-only preview beside the editor. This
item is the real answer.
**Cause (verified in the code).** The composer is a `QPlainTextEdit` over
markdown, deliberately: `OutgoingMessage::markdownBody` is "the source text,
exactly as typed", `MarkdownRenderer` turns it into HTML at build time, and
`DraftStore` autosaves that same markdown. There is nowhere in that model for
a stranger's markup, or for the user's own rich text, to live and be edited.
**Approach.** A rich-text editor for the body, which is what every graphical
mail client does. Thunderbird is the reference: forwarding HTML opens a
rich-text composer with the original inside it, editable.
**This is not a widget swap, and the constraints are why it is L.**
- **The draft format changes.** A draft currently round-trips as markdown; a
rich-text composer means storing HTML, and a draft written by one and read
by the other loses formatting silently. Items 163 and 165 are already about
draft identity and are worth settling first.
- **`QTextEdit`'s HTML subset is narrow.** It is not a browser: real
newsletter markup (tables, modern CSS) degrades in it. So a naive swap makes
the FIDELITY of a forward worse than what 171 currently sends, while making
the editing better. Measure before committing to it.
- **The formatting toolbar has two masters.** `MarkdownFormat` answers "what
does this button do to a selection" over markdown text. A rich-text editor
has its own notion, and the toolbar must drive whichever is active without
the two disagreeing.
- **Plain text must stay reachable.** Not every message should be HTML, and
the `send_html` config default plus the per-message toggle both already say
so. A rich-text composer that cannot produce clean plain text regresses the
common case.
- **The security work does not go away.** A forwarded original is still input
from a stranger. Whatever renders it for editing must not fetch remote
content, and `HtmlSanitiser` (item 171) is what already answers that.
**It subsumes item 133**, markdown syntax highlighting: that is a
`QSyntaxHighlighter` over the plain editor, which is the cheap answer to the
same want ("show me what I am writing"). If this is built, 133 is moot; if
this is deferred, 133 is the thing to do instead. Do not build both.
## 175. The send countdown says Undo, and cannot be skipped
**Observed (user, from the notes):** "the countdown popup has a 'undo' button
that would read better as 'Abort'", and "we could add a 'Send' that skips the
countdown and sends right away."
**Cause.** Not a defect, a wording and a missing control. `SendDialog` runs
the undo window from item 123's design; the button is labelled for the undo
stack's vocabulary rather than for what it does here, which is to stop
something that has not happened yet.
**Approach.** Rename the button, add a second one that fires the send
immediately. Both live in `SendDialog`; the timer already ends in the same
call the button would make, so skipping is stopping the timer and calling it.
**Constraints.**
- The countdown IS the undo for a send, per CLAUDE.md's no-confirmation rule.
A skip button must not become a default, or the protection is gone for
everyone who learns to press it.
- Which button is the default on Return matters here and is the user's call.
## 179. Undo is one level deep and has no Redo
**Observed (user, from the notes):** "ctrl+Z should work like in any other
application, keep track of the past N actions and allow to undo them. This
would also bring in Redo, which would act as the opposite of Undo. As it is
today it feels hackish and half-implemented."
**Cause, verified in the code.** The stack is real, not a single slot:
`MainWindow` owns a `QUndoStack` (`mainwindow.h:1434`) and every mutation
pushes a command onto it, so multi-level undo is already there and works.
What is missing is the other half of the facility around it:
- **There is no `redo` action at all.** `KeyMap::knownActions()` lists `undo`
and nothing else (`keymap.cpp:69`), `Ctrl+Z` is its only binding
(`keymap.cpp:201`), and `QUndoStack::redo()` is never called from
`mainwindow.cpp`. The `redo()` overrides in the command classes exist only
because `QUndoStack` calls them on push; nothing reaches them a second time.
- **The stack is cleared on every new query** (`mainwindow.cpp:3458`), with a
correct reason recorded beside it: the entries invert model updates against
rows that are about to be discarded. That is what makes a stack that is N
deep behave like one that is one deep, since running a query is the ordinary
thing a user does between actions.
- No `setUndoLimit()` is set anywhere, so the depth is unbounded until a query
clears it.
**Approach.** Two separable pieces, and the second is the real work.
1. Add `redo` as an action: `KeyMap::knownActions()`, an icon, a menu entry,
and `Ctrl+Shift+Z` as the conventional default. Per CLAUDE.md that is five
places, four of them enforced by tests. Small and self-contained.
2. Decide what survives a query. The clear is not gratuitous, and removing it
without an answer reintroduces the half-applied undo it prevents: the
database changes and the list does not. The candidate answer is that a
command should be able to re-resolve its rows against the current model
rather than assuming the ones it was built with are still there, at which
point a query no longer invalidates the stack.
**Constraints.**
- The undo stack is this application's substitute for a confirmation dialog,
per CLAUDE.md, so an undo that damages state is worse than the dialog it
replaces. Item 176 is exactly that failure and is one commit old.
- Redo re-applies a write to real mail. It carries the same requirement item
176 established for undo: it must cover what the write actually CHANGED, not
what it asked for.
- `m_awaitingTagConfirmation` holds pointers the stack owns and is cleared
beside it (`mainwindow.cpp:3462`). Anything that changes the clear has to
keep those two in step or it is a dangling pointer, not a stale row.
- Sizing the second piece needs the first: `?` until the query-clear question
is answered.
## 180. The repaint rules are discovered one hole at a time
**Observed (user, from the notes):** "should we refactor the list UI to be
responsive so changes are applied immediately instead of waiting for a view
change to repaint? I feel like we are chasing our own tail with the repaint
issue, we are effectively plugging holes, every time we have to stumble on a
view that doesn't refresh when it should, or worse, it refreshes when it
shouldn't."
**Cause, verified in the code.** This is a maintenance item about a pattern,
not a defect with a reproduction. The observation is accurate and the history
is in this backlog: items 105, 107, 109, 110 and 170 are each one hole in the
same surface, every one found by hand-testing rather than by a test, and each
fixed in place. The current shape is three separate mechanisms that must agree
by hand:
- the optimistic REPAINT, `applyTagChange` for a thread and
`applyMessageTagChange` for a message, which item 105 established must exist
in both scopes and item 107 established must reach loaded replies;
- the optimistic MEMBERSHIP, `MainWindow::syncViewMembership()`, added by item
170 and called from three funnels (`mainwindow.cpp:5861`, `:7052`, and the
deferred path at `:3899`);
- the revert, `revertPendingTagChange()`, which must cover every scope the
other two apply.
CLAUDE.md already carries the rule that keeps them in step ("Every path a
thread-scoped write travels, a message-scoped one travels too"), which is a
statement that the coupling is enforced by documentation and review rather
than by the code.
**Approach.** Not a rewrite. The cheapest thing that would end the pattern is
a single test that asserts the invariant directly, rather than one test per
hole: for each scope and each funnel, a write leaves the model, the membership
and the revert path agreeing. That converts "we stumble on a view that does
not refresh" from a hand-test finding into a suite failure.
**Constraints.**
- The deliberate lag documented in CLAUDE.md is NOT a hole and must not be
"fixed" by such a test: reading the last unread message of a long
conversation leaves the row in place because `applyMessageTagChange` leaves
a long thread's summary alone, and judging on a stale union would be wrong
in both directions. The invariant has to be written to permit it.
- Likewise the two deliberate asymmetries: a row is never evicted while it is
current, and an automatic write defers eviction where a requested one does
not.
- This needs the user to say whether they want the test or the refactor. The
note asks a question rather than reporting a fault, and the answer changes
the size from S to L.
## 183. `undoingAMarkReadRestoresOnlyWhatWasUnread` fails about 1 run in 9 under the full suite
**Observed, 2026-08-29**, while verifying the `thread-row-identity` merge. A
full `ctest` run reported TWO failures in `test_mainwindow` where every earlier
run had reported one: the known `undoMovesTheMessageBack` (item 136) and
`undoingAMarkReadRestoresOnlyWhatWasUnread`, which had never failed before.
**Measured rather than theorised**, because the first reading was wrong: it
looked like a regression from the three commits made that day, and it is not.
| condition | runs | this test failed |
|---|---|---|
| `d7c4d03`, before those commits, standalone | 1 | 0 |
| master, standalone binary | 4 | 0 |
| master, `ctest -R mainwindow` | 3 | 0 |
| master, FULL parallel `ctest` | 3 | **1** |
The base commit shows the same single known failure and the same test count
relationship (301 passed there, 304 on master, which is exactly the three tests
those commits added). So this is a pre-existing flake that happens to be rare
enough not to have been seen before.
Note `ctest -R mainwindow` is NOT a reproduction of the condition: it runs one
suite, so there is no contention and it proves nothing about load. Only the
full run reproduces it, which is why the counts above separate the two.
**Why this one matters more than an ordinary flake.** It is item 176's
regression test. That defect inverted a thread-scoped undo over every message
in the conversation, marked 43 of 44 messages unread on the user's real mail,
and `maildir.synchronize_flags` would have carried it to the server. A guard
that fails under load is a guard that cannot be fully trusted, and this one
stands in front of the worst bug this project has shipped.
**Cause, read from the test rather than measured.** It is `WorkerBackedWindow`
over a real notmuch database and it waits three times with
`QTRY_VERIFY_WITH_TIMEOUT(..., 15000)`, each polling `notmuchCount()`, which
opens the database on every poll. The full suite runs 43 suites in parallel and
several of them are notmuch-backed, so the polls contend for the index and the
15s budget can expire before the write lands.
That is the same shape item 136 records for itself: three 15s timeouts giving
45s against a whole-suite run, and a failure that means the work never happened
rather than that a race was lost. **They are probably one defect**, which would
make 136 more valuable to solve rather than less.
**Approach.** Not decided, and this needs measuring before it needs code. The
question is whether the timeout is too short for a loaded machine or whether
the write genuinely never happens under contention, and those want opposite
fixes. Instrument what the count actually is when the wait expires: item 136's
own row records that the assertion which fails names the real question, and the
same discipline applies here.
**Constraints.**
- Do NOT lengthen the timeout to make it green. A test that passes because it
waited longer hides whichever of the two causes is real, and this test's job
is to fail when the undo is wrong.
- Solve it with item 136 or immediately after it. Two flakes in one binary with
the same shape are one investigation, and fixing one without the other leaves
the suite with a failure that masks the next real one.
- The suite baseline is currently ONE known failure. Anything that makes it two
intermittently costs the property that a red suite means something.
## 187. There is no Spam view beside Trash
**Observed.** The user asks for a Spam view next to Trash. Mail can be marked
spam today and there is no filter that lists it.
**Cause.** `kQueryGenerators` (`config.cpp:62`) is a closed set of six:
`unread`, `inbox`, `flagged`, `sent`, `drafts`, `trash`. There is no `spam`.
The `spam` action has existed since the first toolbar and writes the tag
(`mainwindow.cpp:1770`, adds `spam`, removes `inbox`), so the write half is
built and the read half is missing.
**Two wrong premises were corrected before any design, and both are worth
keeping.** This entry first said no account names a spam folder, so a tag
generator was the only option. Wrong: the accounts synced with `Patterns *`
had a spam folder all along. It then said the accounts with an explicit
`Patterns` list could never have one. Also wrong, and the cause was local
rather than remote: the provider exposes the folder over IMAP and mbsync was
simply never asked for it. Adding it to those three channels on 2026-08-29
took one line each, verified against `mbsync --list` rather than guessed,
which matters because `Create Both` turns a wrong folder name into a folder
created on the server (item 103).
**So every account can now reach a spam folder, and the design is Trash's.**
The user settled three things on 2026-08-29:
- **Path-based, exactly like Trash.** Not a tag generator. A tag query finds
only what this application marked and misses everything the server filed,
which is most of what those folders hold.
- **Mark spam MOVES the file**, as Delete does. This is a change to an
existing action, not only a new view, and it is the part that makes the
path-based view honest.
- **`Junk` is out of scope.** One account has a `Junk` folder beside its
`Spam`; it is not used and the key names one folder.
**Approach.** Follow item 103's implementation rather than inventing one.
1. A mandatory per-account `spam` key beside `trash`, an `Account::spamQuery()`
beside `trashQuery()`, and `Config::allSpamQuery()` beside
`allTrashQuery()`.
2. `spam` added to `kQueryGenerators` and to `builtinFilter()`, threaded like
Trash rather than flat, composing with the account selector through the same
path in `resolvedQuery()`.
3. The `spam` action moves the file instead of only writing tags, through
`moveMessages()`, with an origin tag so it can come back. Restore already
reads `deleted-from:`; this needs the same for spam, or one shared origin
scheme.
4. A cleanup pass for mail tagged `spam` that never moved, which is every
message the action has ever touched.
**The cleanup pass has a precedent and should copy it.**
`showStrandedDeletedMail()` (item 103) is the same problem one version earlier:
mail tagged `deleted` whose file never left its folder. It builds
`tag:deleted and not (<all trash folders>)`, puts it in the query bar, and
REPORTS, moving nothing, leaving the user to select and act. Do the same with
`tag:spam and not (<all spam folders>)`. Two details of it are load-bearing:
an empty folder list must never be written as `not ()`, which notmuch parses
happily and matches nothing, reporting a clean database; and it runs
`AlreadyScoped` so the account dropdown does not narrow it and hide other
accounts' stranded mail.
**Constraints.**
- **A mandatory key breaks every existing config on upgrade**, exactly as
`trash` did under item 103. That needs an `### Upgrading` note in the
changelog, and the same treatment `trash` got: name the missing key rather
than failing silently.
- **Naming a folder that does not exist reaches the server.** Item 103's
lesson, and the reason the three Gmail patterns were verified against
`mbsync --list` before being written. A default value is not safe here; the
key is named by the user or the account has no spam view.
- **`Config::matchNothingQuery()` for an account with no spam folder**, never
an empty string: notmuch reads an empty query as "match everything", so the
Spam button would show the whole Maildir.
- **The trash view's own predicate must not be confused by this.**
`everySelectedRowIsInATrashFolder()` decides which actions the message bar
and menus offer (items 185, 186). A spam folder is not a trash folder and
must not satisfy it, or Restore and the purges appear on spam.
- **Mark spam removing `inbox` stays.** The tag half is still what makes the
message leave the Inbox view; the move is in addition to it, not instead.
- **The label is translated, the generator is not.** `spam` is stored in
`queries.json` and matched against a closed set, so it is wire format; see
the `flagged`/"Important" note in `builtinFilter()`.
- **Adding a generator changes queries.json's readable set**, so an older build
reading a file that names `spam` reports an unknown generator and KEEPS the
row. Existing behaviour, no version bump.
## 188. Does Empty trash respect the account selector?
**Answered on 2026-08-29 by reading the code; no work follows from it.** It
does. `MainWindow::emptyTrash()` (`mainwindow.cpp:6567`) reads
`m_accountBox->currentData()`, uses `Config::allTrashQuery()` only when that is
empty and the account's own `trashQuery()` otherwise, and the confirmation
dialog names the scope ("every account" or the account's display name).
Recorded rather than dropped so the notes' question has an answer here, which
is where the user will look for it.
## 193. The composer has no headings control
**Observed (user, from the notes):** "headers dropdown in the editor. H1 to H6
translating to #,##... for markdown, already supported by the html render."
**Cause, verified in the code.** Two halves, and the note is right about both.
The renderer half is already done. `MarkdownRenderer` runs cmark-gfm with
`CMARK_OPT_DEFAULT | CMARK_OPT_SAFE` and attaches the four GFM extensions
(`src/markdownrenderer.cpp:74`). ATX headings are CommonMark CORE rather than an
extension, so `## Heading` already parses and renders today; nothing about the
render path needs touching. A user who types the hashes by hand gets a heading.
The composer half does not exist. The formatting row is built inline in
`ComposeWindow` (`composewindow.cpp:632` onwards) and offers exactly Bold,
Italic, Code, Strikethrough, Link and Quote. There is no heading action, and
`KeyMap::knownActions()` carries none.
**The shape is `quote()`'s, not `wrap()`'s, and that is the whole difficulty.**
`applyFormat()` goes through `MarkdownFormat::wrap()`, which brackets a
selection with a token on each side. A heading is a LINE PREFIX, so it cannot be
expressed that way, exactly as `MarkdownFormat::quote()` records for `> `.
But a heading is not `quote()` either, and the difference is the work. Quote
deliberately STACKS: a second press gives `> > one`, which is a real nesting a
user might want. Headings do not nest, so a second press must REPLACE: `##`
applied to `# x` has to yield `## x`, never `## # x`, and choosing H2 then H1
has to end at `# x`. That makes this the first formatting control that must read
the line's existing state before deciding what to write, which is precisely the
question item 135 raises for the wrap buttons. This item arrives at it early, on
one control, where it is much smaller: a line prefix is unambiguous to detect
(`^#{1,6} `), where a wrap token is not.
**Approach.** A `MarkdownFormat::heading(text, start, end, int level)` beside
`quote()`, pure over values and tested there like the rest of the namespace.
Level 0 means "remove the prefix", which is what makes the control reversible
without a toggle. In the UI the note asks for a dropdown, which is right: six
buttons would swamp a row that has six controls in total.
**Constraints.**
- A line prefix must be idempotent per level and must not stack. The test that
matters applies H2 to an existing H1 and asserts one prefix survives.
- Multi-line selections: a heading applies per line, like quote. Whether
heading a paragraph of five lines makes five headings is a decision, and the
cheap honest answer is yes, since that is what the markdown means.
- `KeyMap::knownActions()`, the icon table and a menu all need the new action,
per the five-places rule, whether or not it gets a default shortcut.
- Every label needs `tr()`, and the level names (H1..H6) are wire format in the
markdown but labels in the dropdown.
**Size: S.**
## 194. No abuse reporting from a flagged message
**Observed (user, from the notes):** parse a flagged `.eml`, extract IOCs
(sending IP, envelope and href domains, redirect chains), resolve abuse contacts
per IOC via RDAP, generate X-ARF (RFC 5965) reports for each. Fan out to
AbuseIPDB, URLhaus and VirusTotal where keys allow, and to abuse-desk email
where they do not. Back it with MISP via PyMISP for IOC storage, correlation
across reports and dedup. Key submissions on stable IOCs rather than per
message, since campaigns rotate subdomains while reusing infrastructure. Redact
recipient identifiers before submission, and never fetch remote content during
parsing.
**The user wants this and intends to build the sidecar themselves**, stated on
2026-09-08: they are a cybersecurity consultant, they have been phished, and
they want to act fast on a campaign that targets them. Filling a database with
phishing attempts is a feature no other client offers and is a reason this one
exists. Nothing below is a reason not to build it; it is only about WHERE each
part lives.
**One gesture here, the engine in a sidecar.** Recorded in this backlog because
it covers the mail system rather than only this binary, which is why item 166
could land in `assets/hooks/`. The split falls where it does on two grounds:
- **It is network protocol work.** RDAP lookups, three vendor APIs and
abuse-desk email are all outbound network, and this application does no
network protocol work at all by design. Fetching and sending are external
scripts; this would be another one.
- **Its ecosystem is Python.** PyMISP is the reference MISP client, and the
hooks in `assets/hooks/` already establish Python as the language for the
parts of the mail system that are not the GUI.
**What is already here.** `save_message` writes a message out as `.eml`
(`mainwindow.cpp:139`), which is exactly the input such a tool takes, and
`spam` already exists as an action. So qtmaildir's contribution is plausibly
one gesture: "report this", handing a path to a sidecar and reporting what came
back. Item 187 is relevant, since a Spam view is where a user would reach for
this, and 194 should follow it rather than lead.
**Two of the user's own constraints are safety properties**, stated in the note
and not to be traded away for convenience:
- **Redact recipient identifiers before submission.** A report goes to third
parties, so the user's own addresses, folder names and message ids must not
ride along. This is the personal-data rule with a network on the end of it.
- **Never fetch remote content during parsing.** Resolving a redirect chain by
FOLLOWING it confirms the address is live to the sender, and fetching a
tracking href is exactly the beacon the message wanted. Chains come from
headers and href text, never from a request.
**Split into two pieces that ship independently.**
*The qtmaildir half is S and does not wait for the sidecar.* A message-bar
button that marks spam and offers to report it, with a confirmation dialog for
the report. Confirmation is right here and is not a contradiction of the
no-confirmation rule: that rule gives the user undo INSTEAD of a dialog, and a
submission to a third party has no inverse to push, which is the same reasoning
that makes `empty_trash` ask (item 118). Submitting is irreversible in the way
that matters, since a report cannot be recalled from AbuseIPDB.
It should be built AFTER 187 and 190, which decide what Mark spam does and put
it on the bar; this adds a second act to that gesture rather than a second
button beside it.
*The sidecar is L and needs a spec.* The user is writing it. What that spec has
to settle before code: which vendors are wanted and where the keys live;
whether MISP is an instance already running or a new dependency; the wire
contract between qtmaildir and the tool (a path on argv is the obvious answer,
with the exit status and stdout as the report); and what qtmaildir shows when a
report succeeds, partially succeeds, or fails, since a fan-out to four
destinations can do all three at once.
## 195. Mark spam leaves the message unread
**Observed (user, from the notes):** "marking a message as spam without reading
it doesn't remove the unread tag."
**Cause.** Verified, not assumed. The `spam` action at `mainwindow.cpp:1786`
calls `tagSelected({ "spam" }, { "inbox" }, ...)`: it names exactly two tags,
so `unread` is untouched by construction. The message leaves the inbox and
keeps counting toward every unread view.
**Approach.** Add `unread` to the removal list of that one call. It is a
two-word change and the surrounding machinery already covers it: the write goes
through `applyTags`, which reports only the ids whose tags actually moved (item
176), so a spam mark on an already-read message pushes no bogus undo, and
`syncViewMembership()` evicts it from Unread on the same funnel as any other
read.
**Constraints.** Item 187 rewrites this action into a file move, so the cheapest
path is to fold this in there rather than shipping a separate commit that 187
then rewrites. Doing it alone is still fine and costs nothing.
**One question for the user.** Whether marking spam should mark READ, or whether
the right answer is that a spam message stops matching the unread views at all
once 187 makes those views path-based. The first is what the note literally
asks for; the second falls out of 187 for free and means an unread spam message
is still honestly unread if it is ever restored. They are not the same and the
choice is theirs.
## 196. Spam is never tagged automatically
**Observed (user, from the notes):** "the app should be able to tag spam
automatically leveraging intel from abusectl."
**Cause.** Nothing tags spam except the user pressing the action. The one
automatic tagging path in the system is the `post-new` hook
(`assets/hooks/mailrules.py`), which applies the rules in
`~/.config/mailrules/rules.json` and knows nothing outside them.
**Blocked on 194.** `~/Programming/GIT/abusectl` exists as a repository and
nothing of that name is on `PATH`, so the intel this item would consume has no
shape yet. What it exposes, and how, is 194's sidecar design.
**Approach, in outline only.** The home for this is the hook, not `src/`:
tagging at sync time is exactly what `mailrules.py` already does, and doing it
in the GUI would mean the tag depends on the application being open. That makes
it a rule sourced from an external lookup rather than from a stored query, which
is a format question for BOTH readers of `rules.json` and needs the procedure in
`AGENTS.md` under "Changing the rule format".
**Constraints.** Two of the hook's safety properties apply directly. It refuses
to remove `unread` or `inbox`, so an automatic spam rule can only ADD a tag; and
it must not consume `tag:new` when its rules fail to load, which matters more
here because an external lookup can fail in ways a stored query cannot. Whatever
the design, a network lookup inside the hook is a new failure mode for a process
that currently runs offline against the local index.
**Ask the user what abusectl would expose before designing this.** A local
database queried per message, a periodically refreshed blocklist file, and a
callable command are three different items sharing one sentence in the notes.
## 197. No way to say a message is not spam
**Observed.** Split out of the item 187 design on 2026-09-10, at the user's
decision, rather than built into it: "maybe we could already provision for a
future 'unmark spam' action so that we can revert a filter decision".
**What already covers half of it.** Restore handles every message this
application moved. Mark spam writes `moved-from:<folder>` and Restore reads it
back, so unmarking is the existing gesture under a different name.
**The real gap is the provider's filter, not ours.** Mail the provider caught
was never in an inbox, arrived directly in the spam folder, and carries no
origin tag. Restore falls back to the account's inbox for exactly this case,
which is a documented guess rather than a recorded destination.
**Two questions decide the shape, and neither is answerable from the code.**
1. Where does a message with no origin go? The account's inbox is the obvious
answer and is still a guess; a user who wants it filed somewhere else has no
way to say so.
2. Should anything tell the PROVIDER its filter was wrong, so it learns? That
is outbound network work, which this application does not do by design. It
would belong in a sidecar, like item 194's.
**No seam is needed in the meantime.** `sendMove()` already takes any
destination and any tag lists, so a Not-spam action is a caller rather than a
capability. Provisioning for it now would be a hook with one hypothetical
caller, which is what YAGNI names.
**Corrected 2026-09-14:** the interface half is item 201. Restore's visibility
is coupled to `everySelectedRowIsInATrashFolder()`, which by design never
answers for spam, so "Restore already covers what qtmaildir moved" was a
capability claim that no surface offered. Read item 201 for the narrower,
decision-free half.
## 198. The unsynced-changes list never says which account a message belongs to
**Observed (user, from the notes):** "when clicking on the bottom right status
bar, there's no way to discriminate what message belongs to what account."
**Cause.** Verified, not assumed. The bottom-right click is the unsynced-changes
indicator, whose list is item 119's `PendingChangesDialog`. Its row struct
(`pendingchangesdialog.h:32-50`) carries a subject, an action, `startsMessage`
and `messageCount`, and nothing else. Five outstanding changes across five
accounts therefore draw as five subjects in one undifferentiated run, and the
user cannot tell which sync will carry which.
**The data is reachable, which is what makes this S rather than unspecified.**
`MainWindow::accountForMessagePath()` (`mainwindow.cpp:6219`) already answers
exactly this question from a path, and is what Delete, Restore and Empty trash
use. `resolvePendingSubjects()` already crosses to the worker with every id and
answers positionally, so the account is one more field on a round trip that
happens anyway rather than a new mechanism.
**Approach.** Add the path (or the resolved account) to what the worker returns
beside the subject, resolve it through `accountForMessagePath()` on the way
back, and draw it on the row that opens each run, where the subject already is.
The positional contract is the thing to be careful with: `onPendingSubjectsResolved()`
refuses a reply whose lengths disagree, deliberately, and a third list has to
be checked the same way.
**One asymmetry to settle first.** A held THREAD edit contributes a row keyed on
a THREAD id, not a message id (`pendingChangeSnapshot()`, `mainwindow.cpp:5699`),
and thread rows are already drawn differently, carrying a message count where a
message row carries -1. A thread can in principle hold messages from more than
one account, so the thread rows need their own answer rather than the message
one: the account of the thread's first message is a guess, and naming two
accounts on one row may be the honest output. Decide that before writing the
resolver, since it changes what the worker has to return.
**Constraints.** The account name is config text, not mail content, so it is
safe to draw plainly; the subjects beside it are already the untrusted half and
are already handled. Nothing here touches a write path, so there is no undo
question.
## 199. The window chrome uses the system icon theme, and the user wants a shipped set
**Observed (user, from the notes):** "we should ship our own icons, color
themeable to be consistent in every theme a user may implement, since icons are
a brand identity."
**This reverses item 70 rather than completing it, and that is the point.** Item
70 answered the same question in 2026-08-11 and drew the line deliberately:
the PANES are ours, the CHROME is the system's, six SVGs shipped for the marks
and `QIcon::fromTheme` kept everywhere else. `AGENTS.md` records that split as
the item's whole purpose. The note asks for the other half, so this is a design
decision to revisit with the user, not a defect and not an oversight.
**Cause.** Verified. Every chrome icon comes from the desktop theme: the
`themeIcons` table at `mainwindow.cpp:2211`, the built-in filter icons at
`mainwindow.cpp:3124`, and six `QIcon::fromTheme` calls in `composewindow.cpp`.
The consequence the note names is real and already observed in this backlog:
item 190 found that `bug` resolves in 0 of 24 system themes, so a button drawn
with it would be blank on most desktops. An icon this project cannot see is an
icon it cannot design against.
**The mechanism already exists and is proven.** `Marks` (`src/marks.h`) carries
its payloads as compiled-in string literals generated from
`assets/icons/marks/*.svg`, which stay the editable originals; `Marks::pixmap`
composites the real colour over a `fill="currentColor"` render with
`CompositionMode_SourceIn`, which is exactly the "color themeable" property the
note asks for, and is why one asset serves a light and a dark palette. The
reason it is not a `.qrc` is recorded in `src/CMakeLists.txt` and applies here
unchanged: a qrc in the static library registers itself from a global
initialiser the linker drops, and the tests link the library.
**So the code is the small half and the ARTWORK is the item.** Item 70 shipped
six marks; this is on the order of forty actions, each needing a drawing that
reads at toolbar size and at menu size. That is why the size is M-L and why it
cannot be narrowed without the user.
**Two decisions from the user before this can be planned.**
1. **Scope.** The toolbar alone is a small set and the most visible one; every
action in every menu is the whole forty. The note says "icons" without
drawing that line.
2. **Whether the system theme stays as a fallback** for an action with no
shipped icon. Keeping it means the two sets sit side by side during the
transition, which is the opposite of the consistency the note asks for;
dropping it means an unshipped action has no icon at all until one is drawn.
Item 70's own test (`noTwoActionsShareAnIcon()`) and the rule that every
action must carry an icon both key on the current table and would need
rereading against whichever answer is chosen.
## 200. qtmaildir cannot be launched at a given account, thread or message
**Observed (user, from the notes):** "the program should accept cli parameters
like `--account` or `--thread`/`--message`, so that another app can launch
qtmaildir opening that account's inbox or a certain message/thread."
**Cause.** Verified. `main.cpp:38-66` walks `argv` with `std::strcmp` and
recognises `--version`/`-v` and `--help`/`-h`, nothing else. Both answer and
return BEFORE `QApplication` is constructed, which is deliberate and documented
in the file: `--version` has to work on a machine where the GUI cannot open.
Anything else on the command line is ignored silently.
**Parsing is the small half.** `QCommandLineParser` is stdlib for this and
replaces the `strcmp` loop, with the one constraint that the early-exit options
must keep answering without a `QApplication`.
**Two things make this M rather than S, and both are the interesting part.**
1. **There is no single-instance mechanism.** No `QLocalServer` or
`QLocalSocket` appears anywhere in `src/`. A second launch therefore opens a
second window against the same notmuch database, and notmuch permits only one
open handle per process, so two processes is two handles and the read-write
burst the write path depends on becomes a contention question. The note's own
framing, "another app can launch qtmaildir", is the case where the
application is usually ALREADY RUNNING, so the useful behaviour is to steer
the running window rather than to start a second one.
2. **A selector has to reach a query the startup path does not take.**
`--account` is close to free, since the account selector and the built-in
filters already compose that query and `Config::resolvedQuery()` exists for
exactly this. `--thread` and `--message` are not: they name a row that may
not be in the configured startup view at all, so the startup path has to
accept an arbitrary query and then select a row within its result, which is
a selection-after-load problem the window solves nowhere else.
**One decision from the user before this can be planned.** Whether a second
launch should hand its arguments to the running window and focus it, or simply
start with a different query. The first is what makes the feature useful to an
external caller and is essentially the whole cost of the item; the second is
close to free for `--account` alone. They are different items sharing one line
in the notes.
**Constraints.** Arguments are untrusted input in the ordinary sense: a
`--thread` value reaches a notmuch query, so it goes through `SearchTerm`'s
quoting like every other query this application builds, rather than being
concatenated at the call site.
## 201. A message in the Spam view cannot be un-spammed, even one qtmaildir put there
**Observed (user, 2026-09-14, testing the `spam-view` branch).** "If a message
is in spam, how do I unmark it spam?" The only built-in answer found was Ctrl+Z
immediately after the move, before any other action; an edit of the `spam` tag
alone leaves the file in the spam folder. The user asked for this to be built on
`spam-view` before that branch merges.
**Cause.** Verified in `src/`. Three facts together:
1. `spam` is one-way, not a toggle. `MainWindow::spamSelected()` always resolves
the selection and calls `spamMessages()`/`spamThreads()`; unlike `delete`
(`mainwindow.cpp:1745`) and `flag` (`mainwindow.cpp:1832`) it never asks
`everySelectedRowHasTag("spam")`, so pressing Mark spam again moves the file
toward the folder it is already in rather than reverting it.
2. Restore is hidden outside the trash. `refreshTrashActions()`
(`mainwindow.cpp:3936`) sets `restore`'s visibility to
`(!haveSelection || inTrash) && !m_replySelectionHidesDelete`, where `inTrash`
is `everySelectedRowIsInATrashFolder()`, which compares `account.trash` only
BY DESIGN so the predicate never answers for spam (the spec requires that, so
Delete is not hidden and Purge not offered there). Restore is coupled to the
same predicate, which is what hid it in the Spam view.
3. The write side already works. `restoreSelectedFromTrash()`
(`mainwindow.cpp:6907`) resolves the origin from the DATABASE and calls
`sendMove()`, which takes any destination and any tag lists. Only the entry
point and the gating are missing.
So item 197's "Restore already covers what qtmaildir moved" is true of the
function and false of the interface. Its stated gap was the PROVIDER-caught
message with no origin; this item is the narrower half, mail qtmaildir itself
moved and can put back, and it needs no new decision.
**Approach.** One of two, and the difference is what the user should decide:
- **Widen Restore into the Spam view**, decoupling Restore's visibility from
`everySelectedRowIsInATrashFolder()` so a message carrying a `moved-from:`
origin can be restored from spam too. Smallest change; makes one action serve
both folders.
- **Add a distinct `not_spam` action** shown on a spam-folder selection, doing
the existing restore and also stripping `spam`. Clearer on the bar and in the
menu, and costs the five registration places the rule names
(`KeyMap::knownActions()`, `defaultBindings()` optional, the icon table, the
action, a menu).
Either reuses `sendMove()` and resolves the origin from the database, never the
model (the message-scoped `restoreSelected()` reads the model, the path item 176
and the spam-view final review warn against).
**Constraints.** A move's origin is resolved by the worker, never the model
(`CLAUDE.md`). An undo covers what the write CHANGED, not what it asked for
(item 176). No confirmation: this is a move and it is undoable. Item 197's open
questions, where PROVIDER-caught mail with no origin goes (the inbox guess) and
whether to tell the provider its filter was wrong, stay out of scope here.
**Verification.** A `WorkerBackedWindow` test using `QTRY_VERIFY_WITH_TIMEOUT`:
mark a message spam, then un-spam it through the new path, asserting the file
returns to the folder it came from, `spam` and `moved-from:` are gone, and the
row leaves the Spam view. Because it is a move, assert the undo as well.
|