aboutsummaryrefslogtreecommitdiffstats
path: root/CHANGELOG.md
AgeCommit message (Collapse)AuthorFilesLines
2026-08-11feat(sent): add a Sent view, flat and by recipientDanilo M.1-0/+8
Adds a `sent` key to [account.*] naming that account's sent folder, and a Sent button beside the saved queries that composes its query from every account carrying one. An account without the key is omitted silently, as a real account may keep no sent mail locally. With no account selected the button spans all of them; selecting one narrows it through the existing scope wrap rather than a second path. Composed at run time rather than shipped as a [queries] entry. A saved query is one fixed string: it cannot narrow to the selected account, and it goes stale the moment an account is added or a provider renames a folder. The design and the measurements behind it are in docs/superpowers/specs/2026-08-11-sent-mail-design.md. Three things there are worth repeating here. The composed path is QUOTED, and that is load-bearing. A real provider nests its sent folder under a bracketed parent, and "[" and "]" are Xapian syntax: unquoted, the query parses rather than matches and returns nothing while looking entirely plausible. Composition happens in one place so there is one chance to get it right, and a bracketed path is pinned in a test. Recipients are opt-in per query, which is a performance contract rather than a preference. notmuch_message_get_header(m, "To") is not served from the index, it reads the message file: folding every thread of a 4411-thread inbox took 38.2 seconds against 251 ms for the 601-thread sent view. The worker skips the walk entirely unless asked, and the refresh path carries the same flag so a background sync cannot blank the column mid-read. Always folding is mutation-tested: the data would be right and only the cost wrong, which nothing else here would notice. The messages reached through the thread are owned by it and freed with it, so recipientsOf() holds them raw and finishes while the thread is alive, exactly as walkReplies does. An NmMessage wrapper there is a double-free. Sent mail is presented flat, and the pane follows. A message you sent otherwise drags in the replies you received, so a view labelled Sent shows conversations rather than what you sent. ThreadListModel::setFlatMode() makes hasChildren() and ReplyCountRole answer differently and changes nothing else; runQuery() sets it on EVERY run, so any other query restores the tree on its way through and the flag cannot outlive the button that set it. The pane needed its own fix for the same reason: the single-message path depends on a field only filled when a thread is expanded, which never happens in a flat list, so loadThread() gained matchedOnly and drops the messages that did not match instead of rendering them as stubs. Recipients replace the sender through the existing SendersRole rather than a new one, so the delegate needs no branch and cannot disagree with the model about which name a row shows. It falls back to the sender when a To header is absent or unparseable, since a blank where a name belongs reads as a rendering fault. Address parsing uses GMime: a display name may contain a comma, so "Rossi, Mario" <m@example.org>, info@example.net is two addresses and splitting reports three. internet_address_list_parse returns NULL for an empty string, which is a crash if unguarded. Backlog item 63.
2026-08-11feat(config): let the date format on a card be configuredDanilo M.1-0/+8
Adds [general] date_format, a QDateTime pattern for the date a thread card shows. Absent or empty means the system locale's short format, which is what every other application on the desktop uses and stays the default. The format reaches the LAYOUT, not only the painter. CardLayout::compute() reserves the date's width from widestDateSample(), so a pattern that arrived only at the drawText call would be elided into a rect sized for the old format, which is the clipping the bold-font fault already produced once. It rides on CardLayout::Input and defaults to an empty string, leaving every existing call site unchanged. Confirmed by mutation: making the width ignore the format fails the test. widestDateSample() memoised its result in a static, which would have sized every format after the first from whichever arrived first. It is a plain call now, costing one QLocale lookup per row, the same as formatting the date. Validation rejects only a pattern whose output is CONSTANT, found by formatting two different instants and comparing. QDateTime::toString() treats nearly every letter as a field, so "banana" formats as "bpmnpmnpm" and "hello" as "22ello": nonsense, but they vary with the instant, and a check claiming to find "no date field" cannot reject them. What harms the user is the pattern that prints the same text on every card, and that is what is refused, with the value named in the message. The model supplies the pattern through DateFormatRole for the same reason it supplies the tag colours: it is the one object here holding config, and a delegate reading config itself would be a second source of truth. Backlog item 62.
2026-08-11feat(ui): give Sync the refresh iconDanilo M.1-0/+6
The Sync button used mail-receive, a mailbox glyph, which reads as "mail" rather than "fetch again". The toolbar follows the desktop's tool button style, so on an icon-only desktop the icon is the whole control and has to carry the meaning by itself. view-refresh is the standard freedesktop name for the action. The existing noTwoActionsShareAnIcon test covers the collision risk that the 0.12.0 Archive/Mark-all-read defect came from, and passes. Also records the backlog reconciliation this came from: items 64 and 65, appended from the user's notes with their causes verified in code. 65 is "full code review and optimization", which names no symptom or measurement and is filed unspecified rather than given a design. Backlog item 64.
2026-08-10release: 0.14.0v0.14.0Danilo M.1-0/+15
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10feat(view): follow a background sync without a keystrokeDanilo M.1-0/+19
The thread list now updates itself when a sync finishes, whether it is empty or populated. New threads appear where the sort puts them, threads that stopped matching leave, and threads whose state changed repaint. Refreshing used to mean re-running the query, which cleared the model, the selection, the message pane and the undo stack, so 0.8.0 declined to do it on a cron timer and asked the user to press Enter instead. The result was a list that quietly disagreed with the database: mail indexed by cron never appeared, and an Unread view read to the end sat empty in front of it. ThreadListModel::reconcile() diffs a result against the current rows by thread id instead, so a surviving thread keeps its row, its persistent index and its loaded replies. Order comes from the result and is never imposed here, which is what makes the sort dropdown authoritative. The undo constraint this was sized around did not exist: no undo entry was ever keyed on a row. ThreadTagCommand stores thread ids and MessageTagCommand stores message ids, and applyTagChange() looks its target up by id, so an entry already survived its rows leaving the view. A thread read out of the current view now leaves the list, which is correct and would otherwise strand the reader, so MessageView grows a notice saying the open thread no longer matches, with a button that re-queries it. Recovery lists the whole conversation, expands it, and restores the message that was on screen rather than reopening at the first one. Ten defects were found building this, nine of them by hand testing: - SyncMonitor::start() polls synchronously, so an idle lock file emits stateChanged(Idle) from inside buildUi() and the first handler to touch a widget segfaults before the window exists. - QTreeView sets a current index when it takes focus with none set, and current drives loading, so new mail opened itself and was marked read without the user having looked at it. Selection is now required. - The notice outlived what it described, both when the pane was blanked and when another message replaced it. - Retiring the "Background sync completed" message left the bar claiming a sync was still running: silent means saying nothing new, not leaving a stale claim on screen. - A thread root sets both the thread id and the message id, so treating the message id as the message-row case discarded it for the commonest way to open a thread. - A freshly queried root does not know its own first message until the tree loads, so recovery selected nothing and left the pane blank. - A user query mid-recovery had its result hijacked by the pending selection. - MessageView emitted the recovery signal with its own members, so a direct connection handed MainWindow references that runCurrentQuery() then cleared by blanking the pane. The ids went empty mid-slot and no recovery ever ran. Every test passed against this, because reaching a slot through invokeMethod copies its arguments. A Qt signal argument is a reference until something copies it. Emitting a member to a slot that can re-enter the emitter is a use-after-write, and it presents as a wrong value rather than as a crash. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10release: 0.13.0v0.13.0Danilo M.1-0/+35
2026-08-10docs: record the card list, and correct what CLAUDE.md claimsDanilo M.1-6/+34
The architecture section described ThreadListView as existing to paint a strip across columns. That was true until this change and is now the opposite of true: it survives only for the expander hit-test. Kept as one paragraph of history, since it explains the file's shape, but no longer stated as current behaviour. Two traps are recorded inverted rather than deleted, because the rule survived its own reason changing. The reply indent is still asserted on where the TEXT lands, but where visualRect lies has flipped: it used to report an indent the text did not have, and now reports none while the text is indented. And Q_ENUM is documented as insufficient for a queued Q_ARG, which cost a silently dropped sort argument. Item 60's recorded cause was wrong and is corrected in place. It was read off master, where the row arithmetic really is current.row() + 1; the branch had already fixed it a commit earlier with threadRowOf(). The entry stays, with the correction, because the reasoning was sound and the tests it demanded now exist. Items 20, 51 and 53 are marked built on the branch rather than done. Nothing is merged and the user has not seen it, which is the whole point of Task 10.
2026-08-10docs: record message rows, and the user's verdict on themDanilo M.1-0/+13
CLAUDE.md described a QTableView over a table model, which has not been true since the view port. Updated with the traps the port produced, each of which shipped a plausible-looking broken build before being caught: - A tree numbers rows per parent, so nothing may be keyed on a row NUMBER. - drawBranches runs before the row's cells, so an expander on a content column is painted over by the delegate's background. - setRootIsDecorated(false) removes the style's HIT AREA along with its indicator, leaving a glyph that renders and does nothing. - isExpanded and setExpanded are keyed on column 0. - A reply's indent must beat the account chip's width, and visualRect reports the indent correctly even when nothing is visibly indented. - paintEvent runs after the cells, so a full-row fill erases their text. Also the notmuch ownership rule, which is a double-free if undone: messages reached through a thread are freed with it, so walkReplies holds them raw against this file's own RAII convention. Item 20 is marked built, not done, and item 53 records why. The user's verdict on the finished result was that the table view does not fit the use, said with every cue in and working. That is a design finding rather than a defect: the item shipped exactly what its four decisions specified, and all four were the user's own choices. Recording it as a defect would misattribute the cause; recording nothing would leave the next session building on a rejected design. Item 53 carries the cause verified in code rather than guessed. A message row fills the same five columns as a thread row (threadlistmodel.cpp:275-283 mirroring :428-431), so replies land on the same rigid column boundaries as the threads around them, and the eye reads columns before indentation or tint. The reference the user gave has no column rules through its reply rows at all, and that absence is the one thing three added cues cannot supply.
2026-08-09docs: record items 55 and 58, with their causes correctedDanilo M.1-0/+16
Both backlog entries stated a cause that measurement contradicted, and both would have misled the next reader into fixing the wrong thing. Item 55 blamed the thread view's size hint, computed as the sum of its fixed column widths, and placed the fault on first run. The hint is 256px, not 886: a QTableView does not put its column sum in its size hint, so that mechanism never applied. The trigger is the restore, in a window narrower than the one the position was saved in. Item 58 claimed the zoom range is unenforced and the first render unusable. MessageView::clampZoom() has always enforced exactly the README's 0.5 and 3.0; only the report was missing.
2026-08-09release: 0.12.1v0.12.1Danilo M.1-0/+4
One fix: Archive and Mark all read shipped 0.12.0 sharing an icon, which the same release's toolbar change made visible by removing the text labels that had been disambiguating them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09fix(ui): give Archive its own iconDanilo M.1-0/+8
Item 59, reported by the user against 0.12.0. Archive and Mark all read both used `mail-mark-read`, so with the toolbar following a desktop set to icon-only the two buttons were indistinguishable, despite doing different things: archive removes `inbox` from the selection, mark all read removes `unread` from the whole view. Introduced by item 56 in the same session. `archive` had that name from before, when only eight actions carried icons, and item 56 assigned it to `mark_all_read` as well without checking the table for duplicates. `mail-archive` is also the more accurate name, since `mail-mark-read` describes read state rather than archiving. The test asserts the class rather than the reported pair: a hand-written table of twenty-four names has more plausible duplicates in it, so noTwoActionsShareAnIcon compares every action against every other. It compares cacheKey() rather than the theme name, because two different names resolving to the same art are equally ambiguous on screen, and it guards on every action having an icon first, since on a theme that resolves nothing the comparison loop would never run and pass vacuously. Mutation-checked with a different collision. Item 56's own probe is what let this through: it verified every name resolves to non-null art, which is true of two names resolving to the same art. Resolving and being distinguishable are separate properties. The replacement was picked by rendering both at 24px and comparing the images. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09release: 0.12.0v0.12.0Danilo M.1-0/+21
Collects the per-account sync, the cron-sync indicator fix, and the toolbar and icon work that had been sitting unreleased since 0.11.0. A minor bump rather than a patch: two user-visible behaviours changed. The Flag action is now called Important, and the toolbar follows the desktop's own button style instead of always showing text beside icons. Both are documented under Upgrading, along with the saved-query label that a user may want to rename to match. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09feat(ui): make the toolbar icon size configurableDanilo M.1-0/+4
Follow-up to item 56. With the toolbar now following the desktop's button style, an "icon only" desktop makes the icon the whole control, and this style reports PM_ToolBarIconSize as 16px, which is a small target for a button with no text beside it. A [general] toolbar_icon_size key, 16 to 64, defaulting to 24 rather than to the style's own metric. Setting it to 16 restores the theme's value. Clamped and reported, unlike message_zoom, which documents a 0.5 to 3.0 range in the README and enforces none of it. Both ends here break the UI that would be used to fix them: too small is an invisible icon, too large is a toolbar taller than the window. The unenforced message_zoom range is recorded as item 58 rather than fixed here, since it is a separate defect that predates this change. Also documents in the README that saved-query button labels are the key names from the user's own [queries] section, which is why the "Flagged" button still read that way after the action was renamed: it is a user's query name, not a string this code owns. The sample config now shows `Important = tag:flagged` to teach the wording the UI uses. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09feat(ui): icons on every action, and rename Flag to ImportantDanilo M.1-0/+26
Items 56 and 57, done together because both touch the action registry. 56. The complaint was inconsistency, not absence: eight of twenty-four actions had themed icons, so two adjacent entries in one menu disagreed and the toolbar laid out an empty slot for the other sixteen. The themeIcons table now covers them all. The fifteen names added were probed against a live icon theme first rather than taken from the freedesktop spec on faith, and the existing null-icon guard still lets a theme that lacks one fall back to text. The second half of the note asked that buttons honour the desktop's "Icon only" setting. They could not: the hardcoded setToolButtonStyle overrode it whatever the user had chosen. It now reads SH_ToolButtonStyle. Dropping the call entirely was tried and rejected, since a bare QToolBar defaults to ToolButtonIconOnly rather than to the platform hint, which ignores the setting just as thoroughly the other way. This is a visible change: on a desktop set to "Icon only" the toolbar now shows icons without text. 57. "Important" over "Starred", the user's pick; the Message menu already has "Mark &spam", so "Starred" would have needed an accelerator from inside the word. Changed the action text, its status tip, the undo description and the star column's tooltip, which still read "Flagged". The tag stays `flagged`. It is wire format that neomutt, the user's saved queries and ThreadSummary::isFlagged() all read, and following the label through to the tag would rewrite the mail store and desynchronise every other tool over the same Maildir. The action name stays `flag` too, since that is the key users write in [keys]. Four tests. everyActionCarriesAnIcon names every action missing one and guards against passing on an empty list; it reported all sixteen before the change. theImportantActionStillWritesTheFlaggedTag asserts on the tag the model actually received, and mutating it to `important` fails that test plus two pre-existing held-edit tests. Also adds the changelog entry for the cron-sync indicator fix, which the commit that made it omitted. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07feat(sync): sync only the accounts with unsynced editsDanilo M.1-0/+16
A sync ran mbsync -a regardless of what changed, so tagging mail in one account fetched all of them. The account set was not a parameter anywhere on the path: MailSync::start() took no arguments and the script hardcoded -a, so nothing between a tag edit and mbsync carried which account changed. Track which accounts have edits and pass their mbsync channels through to the script, which now takes channel names and falls back to -a when given none. An empty set means all accounts, per the request: a sync with nothing pending is a fetch, and narrowing that to wherever the last edit landed would quietly stop collecting mail everywhere else. The channel is a new optional per-account key rather than the section key. The two names genuinely diverge, because a QSettings section key may carry dots that the channel does not, and mbsync treats an unknown channel as fatal rather than skipping it, so key-as-channel would fail those accounts' syncs outright rather than degrade. It defaults to the key, so accounts whose two names already agree need no config change. The edited-account set is deliberately not netted the way the pending-edit map is: that map tracks the index, where a tag removed and re-added leaves nothing outstanding, while this tracks the mail store, where both writes have already renamed files that mbsync still has to propagate. It is also snapshotted before flushHeldEdits(), which inserts into it synchronously rather than on a queued reply, so a successful sync cannot clear accounts whose edits it never carried. Closes item 49. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07release: 0.11.0v0.11.0Danilo M.1-0/+32
The empty message pane now carries the application's own identity and the counts worth knowing, and Escape finally does what it does everywhere else. Collects the placeholder pane, the Maildir overview, the Escape change, and the thread-list work that had been sitting unreleased since 0.10.0.
2026-08-07feat(ui): show each thread's tags under its rowDanilo M.1-6/+14
The thread list was uniform and cramped: every row one line tall, with nothing to say what a thread was about before opening it. Rows are now roughly double height, carrying a strip of tag chips beneath the text, with alternating row colours and a star column for flagged threads beside the existing paperclip. The strip is painted by the VIEW rather than by a delegate, which is why ThreadListView exists. A delegate is handed one cell's rectangle and cannot paint outside its column, so a strip drawn from the subject column stops at that column's edge, losing the last tags of a well-tagged thread, and starts at its left edge, putting the chips under the subject instead of under the row. Tags the row already shows another way are left out: inbox as structure, unread as the dimming, flagged as the star, attachment as the paperclip, and the account as the chip in the subject cell. Sorted, since notmuch's order is not guaranteed stable and a row whose chips reordered between repaints would flicker. Six defects were introduced and fixed on the way here, all of them one consequence: a QTableView paints per cell, and a row-wide strip is not a cell. SubjectDelegate installed view-wide drew the account chip into every column, since AccountLabelRole belongs to the row; it is split into RowStyleDelegate for every column and SubjectDelegate for the subject alone, with a Q_ASSERT guarding that. Row height returned from sizeHint did nothing, because a table takes one height per row. The strip painted from x=0 over the marker columns, via a protected viewportMargins() that returns 0. Measuring the text band and the strip with one font put the pills over the date. Alternating colours and the selection are per-cell too, so the band showed bare viewport background until the view filled it, honouring the model's own BackgroundRole first so a deleted row is not cut in half. And that fill spanned the full width, cutting the centred marker glyphs at their midpoint. Closes item 5. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07fix(ui): tell read threads from unread without relying on boldDanilo M.1-0/+6
Bold was unread's only cue, and it renders identically to regular on the user's system: confirmed by eye against a bare QTableView holding a plain QStandardItemModel, with no code from this project involved. The fault is in Qt or fontconfig, below this application, and nothing in the model could ever have reached it. Read and unread mail looked exactly alike. The emphasis is inverted instead. Unread rows keep the palette's own text colour and read rows are dimmed toward the background, so the cue rides on Qt::ForegroundRole, which the delegate already honours, and costs no column. It also suits the real ratio, measured at 99 unread against 4220 read: dimming the bulk is calmer than highlighting it. The dim colour is derived from the palette, never hardcoded, per the rule item 12 established. Bold is kept for systems where it works, but nothing depends on it now. That exposed a second defect, visible the moment it shipped. Qt resolves ForegroundRole into the palette and then prefers it over HighlightedText, so a model-supplied colour wins on a SELECTED row too. The dim is blended against the unselected background, so a selected read row painted grey on the selection colour, near unreadable. SubjectDelegate::initStyleOption now reverses that, and the delegate is installed view-wide rather than on the subject column alone, so every column gets the same handling instead of three of them keeping Qt's ordering. The guarding tests state the property rather than the mechanism: strip the font from the model's answer and the two states must still differ. A test asserting only that bold is set passes on a system where bold paints like regular, which is exactly how this survived. The selection test renders two rows identical but for the unread tag, selects both, and requires zero differing pixels. Part of item 5; the density work and the star column remain. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07fix(ui): the message pane follows the desktop themeDanilo M.1-0/+7
The stylesheet hardcoded #bbb, #555, #000, #666, #ddd and #4a6f8a, and set no background at all, so on a dark desktop plain-text mail rendered as black on white inside a dark window and the web view's own default showed through. The colours now come from a Palette struct derived from QPalette, and are passed into the builder rather than read from qApp inside it, so the stylesheet can be tested against a known palette with no running application. Base and Text rather than Window and WindowText: the pane is a content surface like a text edit, and on many themes those differ. The secondary colours are blends of text and background, not fixed greys. That is the part that makes it work both ways round, since a #555 chosen to read as subtle on white is nearly invisible on #2b2b2b. The quote colour keeps its hue, because "this is quoted" is carried by being a different colour rather than a dimmer one, but is pulled toward the background so it stays readable instead of glowing on dark. A sender's own HTML is deliberately left alone, and a test asserts that so it cannot drift: rewriting a sender's styling would break layouts that depend on it, and a newsletter setting a white background is entitled to stay white. This themes the plain-text render and the chrome around messages, nothing more. MessageView passes its own widget palette rather than the application's, since a style sheet or a themed parent can give the pane different colours from qApp, and re-renders on PaletteChange: the document's colours are baked into its stylesheet at build time, so unlike a widget it does not restyle itself when the desktop theme changes. The load-bearing test asserts the negative, that no hex colour appears in the style block which the palette did not supply. A test checking only that the palette's colours are present passes with a leftover literal still there, and one leftover literal is the whole defect. Confirmed by mutation. Closes item 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-07feat(tags): mark every thread in the view read, in one undoable stepDanilo M.1-0/+33
An action removing "unread" from every thread in the current view, on the toolbar, the Message menu and Ctrl+Shift+U. It deliberately ignores the selection, which makes it the one action in the window that does, and it routes through the same funnel as every other tag change, so it is one write rather than one per thread. Disabled until the query reports its total. Threads arrive in batches, so before then the model holds only what has landed, and an action saying "all" must not silently skip the rest. A greyed control says "not yet" without needing a dialog or a stall the user cannot see. The state is also set at registration, since QAction starts enabled and a window that has not run a query has nothing to act on. Two things came out differently from the plan, both forced by existing code. It carries a default binding, because everyActionHasAShortcut requires every registered action to have one: an unbound action is unreachable from the keyboard, and that invariant is deliberate, so the action was given Ctrl+Shift+U rather than the invariant relaxed. And only the threads that are actually unread are sent, because sending the rest would inflate the pending-edit count with writes that change nothing, and the quit prompt reads that count. A view with nothing unread does nothing, pushes no command and says so: an undo entry that restores nothing is worse than none, since it absorbs a Ctrl+Z meant for the previous action. undoDepthForTesting() is new and exists for a reason worth recording: undo->isEnabled() cannot answer "was a command pushed", because the undo QAction is always enabled and tests canUndo() when triggered. The first version of the no-op test asserted on it and passed against a mutant with the unread filter removed. Closes item 43. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06release: 0.10.0v0.10.0Danilo M.1-0/+43
Tag edits made during a background sync are held and sent when it finishes, rather than stalling the worker and freezing the message pane. Sync is one control on the toolbar instead of two that behaved differently, and the query bar gains a clear button and the saved queries beside it. Minor rather than patch: a visible control was removed, the query row was rearranged, and when a tag edit reaches the database changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.9.0v0.9.0Danilo M.1-0/+38
Corrections from using 0.8.0: net unsynced count, Delete as a toggle, expiring status messages, Sync disabled during a background sync, Esc to blank the pane, and a named default on the quit prompt. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.8.0v0.8.0Danilo M.1-0/+36
Multi-select discoverability and awareness of background syncs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04feat(sync): bring mailsync.sh here, print to stdout, exit truthfullyDanilo M.1-1/+17
The reference sync script lived in the companion mailctl project, which documents that it never calls it: sync and organization are separate there on purpose. qtmaildir is the only thing that runs it programmatically, and two of its behaviours were live bugs here, so it belongs in this repo where the constraints on it are. It printed nothing a caller could see. Everything was redirected to $LOGFILE, so MailSync captured an empty stream and the sync log pane stayed blank, a known limitation carried since 0.1.0. It now writes to both through tee: cron keeps its log, and the pane has something to show. It also ended in an unconditional exit 0, discarding statuses it had already computed. qtmaildir believes that exit code: a failed mbsync reported success, cleared the unsynced-changes count, and would have allowed a sync-on-exit to quit over a sync that never happened, which is exactly the case the exit prompt exists to prevent. It now exits with mbsync's status, or notmuch's when mbsync succeeded. The statuses move through files rather than shell variables because the run block is piped into tee, which puts it in a subshell where an assignment does not survive. Verified against stub mbsync and notmuch binaries in a fake HOME, so no mail or database was touched: output reaches stdout, the log still gets every line, and a failing mbsync or notmuch produces its own exit code rather than zero. Both properties are now recorded in CLAUDE.md, since they exist for this application's sake and an edit that looks like a cleanup would remove them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.7.0v0.7.0Danilo M.1-0/+48
Arbitrary tagging from the UI and the unsynced-edits work are both new features, so this takes the minor slot. The SlackBuild's MD5SUM stays at the previous value until the 0.7.0 snapshot tarball exists, which is after the tag is pushed. Update it with sbodl before building the package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.6.0v0.6.0Danilo M.1-0/+36
Message details and automatic mark-read are both new features, so this takes the minor slot. The SlackBuild's MD5SUM stays at the previous value until the 0.6.0 snapshot tarball exists, which is after the tag is pushed. Update it with sbodl before building the package. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.5.0v0.5.0Danilo M.1-0/+54
Query completion is a new feature, so this takes the minor slot rather than a patch, the accompanying bug fixes notwithstanding. The SlackBuild's MD5SUM is left at the 0.4.1 value on purpose: it can only be computed once the 0.5.0 snapshot tarball exists, which is after the tag is pushed. Update it with sbodl before the package is built. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.4.1v0.4.1Danilo M.1-0/+15
Packaging only. The 0.4.0 tarball predated QTMAILDIR_BUILD_TESTS, so a package built from it accepted -DQTMAILDIR_BUILD_TESTS=OFF and ignored it. This tag is the first whose source honours the flag. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.4.0v0.4.0Danilo M.1-0/+37
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.3.0v0.3.0Danilo M.1-0/+51
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04release: 0.2.0v0.2.0Danilo M.1-3/+32
2026-08-04feat: use the application iconDanilo M.1-2/+24
The icon was committed in a previous session and referenced nowhere: no qrc, no .desktop entry, no setWindowIcon. It is wired up now, as a window icon, a desktop entry, and install rules placing both into hicolor and share/applications. resources.qrc belongs to the executable rather than to qtmaildir_lib. A qrc compiled into a static library registers itself from a global initialiser, and the linker discards that object because nothing references it: the build succeeded, qInitResources_resources() was present in the .a, and QFile::exists(":/icons/qtmaildir.svg") still returned false at runtime. Verified loading at 16, 32 and 64 pixels after the move. Toolbar and menu actions take icons from the system theme by their standard names, so they match the rest of the desktop rather than shipping bespoke art. A theme lacking one leaves that action as text, which still works.
2026-08-04feat: show that a tag action landedDanilo M.1-0/+7
Selecting a thread and hitting Delete changed nothing on screen, so there was no way to tell the action had stuck. The tag was always applied: applyTagChange() emitted dataChanged across the row, and the Tags column did update. But Subject was set to stretch while Tags came after it, so Subject took all free width and pushed Tags out of view. The feedback lived in the one column that could not be seen. Columns are now Tags, Date, From, Subject, with Subject stretching last so nothing can be pushed off the right edge. A thread tagged deleted or spam fills its whole row, muted red or orange with white struck-through text, through the background, foreground and font roles, so no cue depends on one column remaining visible. Strike-through accompanies the fill on purpose: it survives a theme that overrides backgrounds and reads without colour. Bold for unread still composes with it. Archive adds no tag, so an archived row is left unstyled for now.
2026-08-04feat: add menus, a toolbar and a shortcut referenceDanilo M.1-1/+31
Actions were a QHash of std::function dispatched by an event filter, which nothing could put in a menu. They are QActions now, bound from KeyMap so a [keys] override reaches the menus as well as the keyboard. Menu bar covers every action; the toolbar carries only Sync, Archive, Delete and Undo. Help > Keyboard shortcuts is generated from the actions, so it shows what the keys really do rather than a copy that drifts. spam and load_remote gained defaults, having been unreachable without a hand-written binding. The event filter is gone. Probing showed QAction shortcuts are dispatched before the focused widget sees the key, so they beat QAbstractItemView's type-to-search without one, and Qt already suppresses plain-letter shortcuts while an editable widget has focus. Dropping the filter's blanket guard also lets Ctrl+Q work while the query bar has focus. registeredActionNames() is derived from the actions rather than hand-maintained, so the two drift tests it needed are replaced by checks that a configured binding reaches its action. No confirmation dialogs: tag mutations still answer to undo.
2026-08-04docs: add README, changelog and release procedurev0.1.0Danilo M.1-0/+70
README covers what the project deliberately does not do (no POP, IMAP or SMTP, no send in v1, no confirmation dialogs), the verified dependency versions, build and test commands, the full config format with the reasons behind its two surprises, the keybinding table, and the security posture of the message view. Two facts in it were checked rather than assumed: the `spam` and `load_remote` actions are registered but have no default binding, so they are documented as unreachable until bound; and the attachment path guard does exist as described. The Release build was also verified to pass all 11 test binaries, which matters because Q_ASSERT compiles out there and one of the cid: invariants leans on an assertion in debug. CHANGELOG.md follows Keep a Changelog and records 0.1.0 along with the current known limitations. docs/RELEASING.md records what semver means for a mail client (the config format and action names are the public interface) and the steps, including that the version is bumped in exactly one place. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>