diff options
| -rw-r--r-- | CHANGELOG.md | 44 | ||||
| -rw-r--r-- | README.md | 11 | ||||
| -rw-r--r-- | assets/icons/marks/received-forward.svg | 20 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md | 116 | ||||
| -rw-r--r-- | docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md | 58 | ||||
| -rw-r--r-- | src/carddelegate.cpp | 3 | ||||
| -rw-r--r-- | src/cardlayout.cpp | 1 | ||||
| -rw-r--r-- | src/cardlayout.h | 6 | ||||
| -rw-r--r-- | src/composecontext.cpp | 52 | ||||
| -rw-r--r-- | src/composecontext.h | 20 | ||||
| -rw-r--r-- | src/composewindow.cpp | 56 | ||||
| -rw-r--r-- | src/composewindow.h | 12 | ||||
| -rw-r--r-- | src/config.cpp | 32 | ||||
| -rw-r--r-- | src/config.h | 16 | ||||
| -rw-r--r-- | src/mainwindow.cpp | 160 | ||||
| -rw-r--r-- | src/mainwindow.h | 10 | ||||
| -rw-r--r-- | src/marks.cpp | 7 | ||||
| -rw-r--r-- | src/marks.h | 7 | ||||
| -rw-r--r-- | src/threadlistmodel.cpp | 95 | ||||
| -rw-r--r-- | src/threadlistmodel.h | 41 | ||||
| -rw-r--r-- | src/types.h | 10 | ||||
| -rw-r--r-- | tests/test_composecontext.cpp | 77 | ||||
| -rw-r--r-- | tests/test_mainwindow.cpp | 306 | ||||
| -rw-r--r-- | tests/test_threadlistmodel.cpp | 97 | ||||
| -rw-r--r-- | translations/qtmaildir_it_IT.ts | 12 |
25 files changed, 1190 insertions, 79 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md index 9588017..e069722 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,15 @@ point at which they are stable. the About dialog and the placeholder pane, and deliberately not in the window title. +- **A mark for mail someone forwarded to you.** A card now carries its own + glyph when the subject reads as a forward, in any of the spellings clients + actually send (`Fwd:`, `Fw:`, `WG:`, `ENC:`, `RV:`, `TR:`), including under + a `Re:` chain. Add more with `[general] forward_prefixes`, which extends + that list rather than replacing it. This is display only: nothing is written + to the message and nothing reaches your mail server. It is deliberately a + different mark from `passed`, which is the Maildir `P` flag and means *you* + forwarded something. + ### Changed - **The unread action says which way it will go.** "Toggle unread" read the @@ -52,14 +61,45 @@ point at which they are stable. - **Delete and Restore appear only where they apply.** Delete is hidden on mail already in the trash, where it reported success and did nothing, and Restore is hidden on mail that was never deleted. -- **Deleting a message also marks it read.** Mail you threw away no longer - counts towards unread. Undo returns both the folder and the tag. +- **Deleting a message also marks it read, and takes it out of the inbox.** + Mail you threw away no longer counts towards unread, and no longer sits in + the Inbox view: `inbox` is stripped along with `unread`, and the row leaves + the list straight away rather than waiting for the next sync. Restore and + Undo both put the tag back with the file, so nothing comes home invisible. + One consequence worth knowing: pressing Delete a second time to undelete is + gone for ordinary mail, because there is no longer a row sitting there to + press it on. Undo retracts, and Restore in the Trash view is the deliberate + route. The second press still works on stranded mail, which is the one place + a message can carry `deleted` without being in a trash folder. +- **The trash no longer paints every row red.** The deleted highlight exists + to tell you a message is on its way out of a view it is still sitting in; in + the Trash that is every row, so it said nothing and cost legibility. Deleted + mail there is now drawn normally, still struck through. A message tagged + spam keeps its tint, since that is still news in a folder that only promises + "thrown away". ### Fixed - **New mail reached the index but not the window.** The worker never reopened its read-only notmuch handle, so nothing indexed after startup appeared in any query and the application looked like it had stopped syncing. +- **Replying or forwarding never flagged the message you answered.** The + Maildir `R` and `P` flags, which every other client sets and which notmuch + reads back as the `replied` and `passed` tags, were never written by + qtmaildir: measured on the developer's own index, all 317 `replied` and all + 6 `passed` had come from other clients. A reply now marks its source + replied, a forward marks its source forwarded, and both reach the server on + the next sync. Neither goes on the undo stack, for the reason the automatic + mark-read does not: the flag records that the mail went, and the send itself + cannot be undone. A reply or forward finished from a saved draft is not + flagged, since a resumed draft cannot be told from a new message. +- **A sent message could leave its draft behind.** mbsync renames an uploaded + draft to add its own `,U=<uid>` marker, while the composer still held the + name it originally wrote, so the removal on send ran against a path that no + longer existed and failed silently. The message went, the sent copy was + filed, and the draft stayed in the Drafts view looking unfinished. The same + rename was already handled everywhere a draft is READ; this was the one + place it is written. - **Mail sent to another of your own accounts lost `inbox`** and was missing from the account that received it. notmuch stores one message with two files in that case, the sender's copy and the recipient's, and the `post-new` @@ -200,6 +200,17 @@ identity. ; pattern that contains no date or time field at all is refused with a message, ; since it would print the same fixed text on every card. ; date_format = yyyy-MM-dd hh:mm +; Optional. Extra subject prefixes that mark a message someone forwarded TO +; you, drawn on the card as a received-forward mark. ADDED to the built-in +; list rather than replacing it, so adding your own locale does not lose +; Fwd:, Fw:, WG:, ENC:, RV: and TR:. +; Bare words, without the colon. A prefix is matched only at the START of the +; subject, case-insensitively, after any Re: chain, so a reply to a forward +; still counts. Entries that are not a plain word are ignored. +; This is display only: nothing is written to the message and nothing reaches +; your mail server. It is unrelated to the "passed" tag, which is the Maildir +; P flag and means "I forwarded this". +; forward_prefixes = Doorst, VS, VL [completion] ; Optional. Extra content types offered after mimetype:, APPENDED to the diff --git a/assets/icons/marks/received-forward.svg b/assets/icons/marks/received-forward.svg new file mode 100644 index 0000000..033ac6c --- /dev/null +++ b/assets/icons/marks/received-forward.svg @@ -0,0 +1,20 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16"> + <!-- Received forward: a shaft entering from upper left, turning down into an + arrow head at the bottom. Deliberately NOT a mirror of passed.svg, which + is the pair passed/replied and means "I acted on this". This one means + "someone forwarded this TO me", so it points down and inward rather than + sweeping away, and is derived from the subject line rather than from a + Maildir flag. --> + <path fill="currentColor" d="M 2.0,2.6 + a 0.95,0.95 0 0 1 1.9,0 + V 6.4 + C 3.9,8.1 5.2,9.4 6.9,9.4 + H 9.6 + V 6.6 + L 15.0,11.0 + L 9.6,15.4 + V 12.6 + H 6.9 + C 3.5,12.6 2.0,10.3 2.0,7.4 + Z"/> +</svg> diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md index 1208c42..39026cb 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md @@ -8491,3 +8491,119 @@ and the `unread` removal. The suite is 37 of 38, the failure being item 136 on an unrelated path, and no new user-facing strings were added. **Size: S** for the visibility half, XS for the `unread` half. Done. + + +## 68. A forwarded subject gets no `passed` tag + +**Observed (user, from the notes):** "passed tag should appear when subject is +`Fwd:` and `Fw:`." Refined in session on 2026-08-11: the user had noticed +`passed` appearing on messages whose subject carried `Fwd:` and not on `Fw:`, +and asked to expand the rule to both. + +**Cause:** there is no rule to expand. `passed` is the Maildir `P` flag in the +message filename, translated into a tag by notmuch because +`maildir.synchronize_flags=true`. The flag is written by whichever client +forwarded the message, or by the server over IMAP; nothing reads a subject line +anywhere in the chain. qtmaildir only ever colours the tag +(`src/tagcolors.cpp:36-37`) and the database's `post-new` hook does not mention +it either. + +**Measured against the real database (2026-08-11):** + +| Query | Count | +|---|---| +| `tag:passed` | 6 | +| `tag:passed and subject:"Fwd:"` | 1 | +| `tag:passed and subject:"Fw:"` | 0 | +| `subject:"Fwd:" and not tag:passed` | 194 | +| `subject:"Fw:" and not tag:passed` | 28 | + +Six tagged messages in the whole database, and every one of them carries `P` in +its filename flags. The single overlap with `Fwd:` is a message that was +forwarded and whose subject was already a forward, not evidence of a rule: 194 +`Fwd:` subjects carry no tag at all. The correlation the observation rests on +does not exist. + +**Approach and the decision it needs first.** Two different features, and the +measurements above decide how far apart they are. + +*Display only.* The card shows a forwarded mark when the subject matches. Touches +no mail, changes no flag, reversible by deleting the rule. XS. + +*Write the tag.* qtmaildir sets `P` from a subject heuristic. With +`maildir.synchronize_flags=true` that flag is a filename change that mbsync +carries out to the server, on 222 existing messages, on a guess about a string. +Not cleanly undoable, and it asserts a meaning for a flag this application did +not define. Recommended against; recorded so the choice is deliberate rather than +forgotten. + +**Constraints:** localised clients use their own prefixes, and `Fwd:` can appear +inside a subject rather than at its head, so whatever matches must be anchored. +If the tag is ever written, it must not be re-applied on every sync in a way that +produces pending edits the user never made, item 28 is the record of a count +going wrong. The display-only route avoids that entirely, since it derives the +mark at paint time and stores nothing. + +**Size: S** as written, XS if it is display only. Most of it is the decision, not +the code. + +**Status:** left open deliberately on 2026-08-11. The cause is settled and the +options are costed; the user has not chosen, and no code was written. + +**Built 2026-08-26, and the observation was wrong in a way worth recording.** +The note asked for one thing (expand a subject rule to `Fw:`) and the +measurement above had already shown there was no subject rule and no +correlation to expand. Taken literally the item was unbuildable; taken as what +the user actually wanted ("I want to know visually if someone has forwarded a +message to me") it split into three, and the user chose all three. + +**1. `replied` on a reply, `passed` on a forward.** The gap the item was +really sitting on, and it was never reported. Measured 2026-08-26 against the +developer's own index: 317 `replied` and 6 `passed`, spread over five +accounts, every one of them written by another client or the server. Nothing +in qtmaildir has ever written either flag. `ComposeWindow` emits +`sourceMessageAnswered` after a SUCCESSFUL send; `MainWindow` routes it +through `sendMessageTagChange`, message-scoped, off the undo stack for the +reason `markCurrentThreadRead` gives (the flag records that the mail went, and +the send cannot be undone, so an undo that retracted only the flag would leave +the two disagreeing). + +**Two traps here, one of which was caught only by reading.** `inReplyTo` is +deliberately EMPTY on a forward (carrying In-Reply-To would file the forward +under the thread it left, in the recipient's client), so keying the emit on it +made the `passed` half dead code that compiled and never fired. `ComposeContext` +carries `sourceMessageId` instead, set for all three kinds. And a resumed +`Kind::Draft` is excluded: its kind records how the FILE was opened, not what +the user is doing, so a draft that began as a reply cannot be told from one +that began as a new message. The cost is a missing flag on a reply finished in +two sittings, which is the safe direction, since `maildir.synchronize_flags` is +on and a wrong flag reaches the server. + +**2. A received-forward mark, display only.** `Marks::Mark::ReceivedForward`, +a seventh SVG, drawn from `ThreadListModel::IsReceivedForwardRole` in BOTH the +thread and the message branch per CLAUDE.md's rule. It is a different mark +from `passed` on purpose: `P` means "I forwarded this", which is a different +fact about a different person, and setting it from a subject guess would +assert something false on 222 existing messages and propagate it to the +server. Derived at paint time, stores nothing. + +**3. `[general] forward_prefixes`.** `subjectIsForwarded()` lives beside +`forwardSubject()` and shares its prefix table, so "do not double the prefix" +and "this is a forward" cannot drift apart. The config key EXTENDS that table +rather than replacing it, so adding a locale does not lose the measured +English/German/Iberian/French spellings. A `Re:` chain is stripped first +(bounded to 8, since the subject is input from a stranger and this runs per row +per repaint), so `Re: Fwd: x` is recognised. + +**A mutation survived the first round and corrected a claim in the code.** The +word-validation guard on a configured prefix was commented, and tested, as +protecting against an invalid pattern from an unescaped `(`. Measured with a +standalone probe: `QRegularExpression::escape` already makes punctuation inert +rather than invalid, so that test passed against the guard being removed. What +the guard actually buys is narrower and real: a configured `-` would match +`-: x` and a digit would match `2: x`. The comment and the test now assert +that instead. + +**Not built, and left as the item's own recommendation:** writing `P` from a +subject heuristic. Rejected on the same grounds the entry gave before the +work started. diff --git a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md index 437ceda..ffb41ae 100644 --- a/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md +++ b/docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md @@ -136,7 +136,7 @@ taking that too literally. | 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 | open; no subject rule exists, measured 2026-08-11. Decision needed: display mark (XS) or write the flag (S, syncs out) | +| 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` | @@ -374,62 +374,6 @@ recorded. **Size: `?`, unspecified.** Do not propose a design for this; ask. -## 68. A forwarded subject gets no `passed` tag - -**Observed (user, from the notes):** "passed tag should appear when subject is -`Fwd:` and `Fw:`." Refined in session on 2026-08-11: the user had noticed -`passed` appearing on messages whose subject carried `Fwd:` and not on `Fw:`, -and asked to expand the rule to both. - -**Cause:** there is no rule to expand. `passed` is the Maildir `P` flag in the -message filename, translated into a tag by notmuch because -`maildir.synchronize_flags=true`. The flag is written by whichever client -forwarded the message, or by the server over IMAP; nothing reads a subject line -anywhere in the chain. qtmaildir only ever colours the tag -(`src/tagcolors.cpp:36-37`) and the database's `post-new` hook does not mention -it either. - -**Measured against the real database (2026-08-11):** - -| Query | Count | -|---|---| -| `tag:passed` | 6 | -| `tag:passed and subject:"Fwd:"` | 1 | -| `tag:passed and subject:"Fw:"` | 0 | -| `subject:"Fwd:" and not tag:passed` | 194 | -| `subject:"Fw:" and not tag:passed` | 28 | - -Six tagged messages in the whole database, and every one of them carries `P` in -its filename flags. The single overlap with `Fwd:` is a message that was -forwarded and whose subject was already a forward, not evidence of a rule: 194 -`Fwd:` subjects carry no tag at all. The correlation the observation rests on -does not exist. - -**Approach and the decision it needs first.** Two different features, and the -measurements above decide how far apart they are. - -*Display only.* The card shows a forwarded mark when the subject matches. Touches -no mail, changes no flag, reversible by deleting the rule. XS. - -*Write the tag.* qtmaildir sets `P` from a subject heuristic. With -`maildir.synchronize_flags=true` that flag is a filename change that mbsync -carries out to the server, on 222 existing messages, on a guess about a string. -Not cleanly undoable, and it asserts a meaning for a flag this application did -not define. Recommended against; recorded so the choice is deliberate rather than -forgotten. - -**Constraints:** localised clients use their own prefixes, and `Fwd:` can appear -inside a subject rather than at its head, so whatever matches must be anchored. -If the tag is ever written, it must not be re-applied on every sync in a way that -produces pending edits the user never made, item 28 is the record of a count -going wrong. The display-only route avoids that entirely, since it derives the -mark at paint time and stores nothing. - -**Size: S** as written, XS if it is display only. Most of it is the decision, not -the code. - -**Status:** left open deliberately on 2026-08-11. The cause is settled and the -options are costed; the user has not chosen, and no code was written. ## 72. No khard/khal integration diff --git a/src/carddelegate.cpp b/src/carddelegate.cpp index 4e27e6e..9a98c69 100644 --- a/src/carddelegate.cpp +++ b/src/carddelegate.cpp @@ -52,6 +52,8 @@ CardLayout::Input inputFor(const QModelIndex &index) in.flagged = index.data(ThreadListModel::IsFlaggedRole).toBool(); in.hasAttachment = index.data(ThreadListModel::HasAttachmentRole).toBool(); in.passed = index.data(ThreadListModel::IsPassedRole).toBool(); + in.receivedForward = + index.data(ThreadListModel::IsReceivedForwardRole).toBool(); in.replied = index.data(ThreadListModel::IsRepliedRole).toBool(); return in; } @@ -256,6 +258,7 @@ void CardDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, drawMark(card.flagRect, Marks::Mark::Flagged); drawMark(card.attachmentRect, Marks::Mark::Attachment); drawMark(card.passedRect, Marks::Mark::Passed); + drawMark(card.receivedForwardRect, Marks::Mark::ReceivedForward); drawMark(card.repliedRect, Marks::Mark::Replied); // The reply count, which is also the expander, drawn as a PILL. diff --git a/src/cardlayout.cpp b/src/cardlayout.cpp index 3719591..d32935b 100644 --- a/src/cardlayout.cpp +++ b/src/cardlayout.cpp @@ -260,6 +260,7 @@ CardLayout CardLayout::compute(const Input &input, const QRect &rect, target = QRect(markRight - side, markTop, side, side); markRight = target.left() - kMarkGap; }; + placeMark(input.receivedForward, out.receivedForwardRect); placeMark(input.replied, out.repliedRect); placeMark(input.passed, out.passedRect); placeMark(input.hasAttachment, out.attachmentRect); diff --git a/src/cardlayout.h b/src/cardlayout.h index de4c41f..92edd96 100644 --- a/src/cardlayout.h +++ b/src/cardlayout.h @@ -68,6 +68,9 @@ struct CardLayout bool hasAttachment = false; bool passed = false; bool replied = false; + /// Someone forwarded this message TO the user (item 68). Derived from + /// the subject, so unlike the three above it corresponds to no tag. + bool receivedForward = false; }; /// Width of the account accent bar down a thread card's left edge. @@ -111,7 +114,7 @@ struct CardLayout QRect flagRect; /// The state marks after the subject, in this order: attachment, passed, - /// replied. Each is empty when its state does not apply. + /// replied, received-forward. Each is empty when its state does not apply. /// /// Separate rects rather than one strip, because each is independently /// present or absent and a strip would have to encode which. They are laid @@ -120,6 +123,7 @@ struct CardLayout QRect attachmentRect; QRect passedRect; QRect repliedRect; + QRect receivedForwardRect; /// The side of a square mark on line two, derived from the card's font so /// the marks scale with the user's text size rather than being pinned to a diff --git a/src/composecontext.cpp b/src/composecontext.cpp index 7233330..2dfee53 100644 --- a/src/composecontext.cpp +++ b/src/composecontext.cpp @@ -487,6 +487,58 @@ QString ComposeContextBuilder::forwardSubject(const QString &original) return QStringLiteral("Fwd: ") + original; } +bool ComposeContextBuilder::subjectIsForwarded(const QString &subject, + const QStringList &extraPrefixes) +{ + // Strip any Re: chain first, so "Re: Fwd: x" is recognised: a reply to a + // forward is still a forward the user received. Bounded rather than a + // while(true), since a crafted subject of ten thousand "Re:" is input from + // a stranger and this runs per row per repaint. + QString rest = subject; + for (int i = 0; i < 8; ++i) { + const QRegularExpressionMatch match = replyPrefix().match(rest); + if (!match.hasMatch()) + break; + rest = rest.mid(match.capturedEnd()); + } + + if (forwardPrefix().match(rest).hasMatch()) + return true; + + if (extraPrefixes.isEmpty()) + return false; + + // The configured spellings, matched with the same shape as the built-in + // table: anchored, case-insensitive, tolerating the counted forms Outlook + // emits. + // + // Escaped, because this comes from a hand-edited config file. Measured + // 2026-08-26: escaping alone already makes a punctuation entry inert + // rather than invalid, so the word guard below is NOT about pattern + // validity. It is about what a non-word entry would legitimately match: a + // configured "-" matches "-: x", and a digit entry matches a subject + // opening with a number, neither of which is a forward marker in any + // client. + QStringList alternatives; + for (const QString &prefix : extraPrefixes) { + const QString trimmed = prefix.trimmed(); + // A word only. A configured "Re" would swallow every reply, and a + // configured ":" or "" would match every subject in the mailbox. + static const QRegularExpression word(QStringLiteral("^[^\\W\\d_]+$")); + if (trimmed.isEmpty() || !word.match(trimmed).hasMatch()) + continue; + alternatives << QRegularExpression::escape(trimmed); + } + if (alternatives.isEmpty()) + return false; + + const QRegularExpression extra( + QStringLiteral("^\\s*(%1)\\s*(\\[\\d+\\]|\\(\\d+\\))?\\s*:") + .arg(alternatives.join(QLatin1Char('|'))), + QRegularExpression::CaseInsensitiveOption); + return extra.match(rest).hasMatch(); +} + ComposeContext ComposeContextBuilder::forDraft(const Config &config, const QString &path) { diff --git a/src/composecontext.h b/src/composecontext.h index 6f311c7..b94d0f8 100644 --- a/src/composecontext.h +++ b/src/composecontext.h @@ -168,6 +168,26 @@ QString replySubject(const QString &original); QString forwardSubject(const QString &original); +/// True when \p subject reads as a message someone forwarded TO the user. +/// +/// Item 68. This is a DISPLAY predicate: the card draws a mark from it and +/// stores nothing. It must never write a tag or a Maildir flag, because +/// `passed` (the `P` flag) means "I forwarded this" and is a different fact +/// about a different person. Setting it from a subject guess would assert +/// something false and, with maildir.synchronize_flags on, propagate that to +/// the server. +/// +/// Matches the same prefix table forwardSubject() uses, so the set of +/// recognised spellings cannot drift between "do not double the prefix" and +/// "this is a forward". \p extraPrefixes adds locale spellings the built-in +/// table omits, from `[general] forward_prefixes`; each is a bare word without +/// its colon ("doorst", "vs"). An entry that is empty or not a word is ignored. +/// +/// A `Re:` chain is stripped first, so `Re: Fwd: x` is recognised: a reply to +/// a forward is still a forward the user received. +bool subjectIsForwarded(const QString &subject, + const QStringList &extraPrefixes = {}); + /// Builds the context that RESUMES a draft from its file. /// /// Unlike a reply, nothing here is derived: the recipients, the subject and diff --git a/src/composewindow.cpp b/src/composewindow.cpp index 879a9d1..afcf6a2 100644 --- a/src/composewindow.cpp +++ b/src/composewindow.cpp @@ -21,6 +21,7 @@ #include <QTemporaryDir> #include "draftstore.h" +#include "maildirname.h" #include "messagebuilder.h" #include "mimeparser.h" #include "messagesender.h" @@ -1418,11 +1419,62 @@ void ComposeWindow::send() dialog->setStage(SendDialog::Stage::RemovingDraft); if (!m_draftPath.isEmpty()) { - QFile::remove(m_draftPath); - emit draftRemoved(m_draftPath); + // Re-resolved, because mbsync renames an uploaded draft to add + // its `,U=<uid>` infix while m_draftPath still holds the name + // DraftStore::write() returned. Without this the remove is a + // silent no-op on a path that no longer exists: measured + // 2026-08-26 on the user's own mail, where a forwarded message + // was sent and filed correctly and its draft stayed in the + // Drafts view carrying the `D` flag. + // + // Item 163 added resolveRenamed() and wired it into the three + // READ sites (the pane, Reply/Forward, the draft reopen). This + // is the write site, and it was missed: the same rename, the + // same fix, one call site later. + // + // The unresolved path is emitted when nothing matches, so a + // draft that genuinely vanished still asks the worker to drop + // its index entry rather than leaving a ghost. + const QString actual = MaildirName::resolveRenamed(m_draftPath); + const QString target = actual.isEmpty() ? m_draftPath : actual; + QFile::remove(target); + emit draftRemoved(target); m_draftPath.clear(); } + // Item 68. The Maildir R and P flags, recorded on the message this + // one answers. Both were measured missing on 2026-08-26: every one + // of the 317 `replied` and 6 `passed` in the developer's own index + // came from another client, because nothing here has ever written + // either. + // + // AFTER the send, never before: the flag asserts that the mail + // went, and an abandoned composer must leave no trace on the + // message it was answering. + // + // sourceMessageId, NOT inReplyTo: that header is deliberately + // empty on a Forward, so keying on it would have made the `passed` + // half dead code that compiles and never fires. + // + // A resumed Draft is deliberately excluded even when it carries a + // source id. Its kind records how the FILE was opened, not what the + // user is doing, so a draft that began as a reply cannot be told + // from one that began as a new message; flagging on that would set + // R from a guess. The cost is a missing flag on a reply finished in + // two sittings, which is the safe direction: maildir.synchronize_- + // flags is on, so a wrong flag reaches the server. + if (!m_context.sourceMessageId.isEmpty()) { + QString tag; + if (m_context.kind == ComposeContext::Kind::Reply + || m_context.kind == ComposeContext::Kind::ReplyAll) { + tag = QStringLiteral("replied"); + } else if (m_context.kind == ComposeContext::Kind::Forward) { + tag = QStringLiteral("passed"); + } + if (!tag.isEmpty()) + emit sourceMessageAnswered(m_context.sourceMessageId, tag); + } + dialog->accept(); dialog->deleteLater(); diff --git a/src/composewindow.h b/src/composewindow.h index 2eaeeea..c8cc12a 100644 --- a/src/composewindow.h +++ b/src/composewindow.h @@ -189,6 +189,18 @@ signals: /// \p path is the file that was removed, absolute. void draftRemoved(const QString &path); + /// A send succeeded, and the message it answers should record that. + /// + /// Item 68. \p sourceMessageId is the Message-ID of the message replied to + /// or forwarded, \p tag is "replied" or "passed". The window emits rather + /// than writing, because a tag write belongs to the one applyTags path in + /// MainWindow and the composer owns no worker. + /// + /// Emitted only after the send itself succeeded: a failed send leaves the + /// source untouched, since the flag asserts that the mail went. + void sourceMessageAnswered(const QString &sourceMessageId, + const QString &tag); + protected: /// The one place the registry is told, whichever route closes the window. void closeEvent(QCloseEvent *event) override; diff --git a/src/config.cpp b/src/config.cpp index d91259a..8b784ba 100644 --- a/src/config.cpp +++ b/src/config.cpp @@ -170,6 +170,13 @@ QString Account::inboxQuery() const return folderQuery(maildir, inboxFolder()); } +QString Config::generatorTagFor(const QString &generator) +{ + // Delegates to the file-local table rather than repeating it, so the + // question "which tag does this filter match" has one answer. + return generatorTag(generator); +} + QString Config::allSentQuery() const { return joinAccountQueries(m_accounts, &Account::sentQuery); @@ -338,6 +345,31 @@ void Config::load(const QString &path) } } + // Extra subject prefixes that mark a message someone forwarded TO the + // user, added to the built-in table in composecontext.cpp rather than + // replacing it: the built-ins are the spellings that repo already + // measured, and a user adding Dutch should not have to restate English. + // + // Bare words, no colon. The predicate ignores anything else, so a + // malformed entry costs that entry and not the whole key. + const QStringList forwardPrefixes = + settings.value(QStringLiteral("forward_prefixes")) + .toStringList(); + for (const QString &prefix : forwardPrefixes) { + const QString trimmed = prefix.trimmed(); + if (trimmed.isEmpty()) + continue; + // Warned rather than dropped silently: the user asked for something + // and is not getting it, the same reason message_zoom warns. + if (trimmed.contains(QLatin1Char(':'))) { + addProblem(tr("Forward prefix '%1' should be written without " + "its colon; ignoring it.") + .arg(trimmed)); + continue; + } + m_forwardPrefixes << trimmed; + } + // Absent is silent, the default being 2000. Present but unparseable warns, // for the same reason message_zoom does: the user asked for something and // is not getting it. diff --git a/src/config.h b/src/config.h index 02b4038..26fc1c1 100644 --- a/src/config.h +++ b/src/config.h @@ -349,6 +349,14 @@ public: /// account that configures no sent folder would show the entire Maildir. static QString matchNothingQuery(); + /// The tag a built-in generator matches, or empty for the folder-backed + /// ones (`sent`, `drafts`, `trash`) which compose from a path instead. + /// + /// Exposed so a caller asking "which tag decides membership of this view" + /// reads the same table the query generator does, rather than keeping a + /// second copy that can drift from it. + static QString generatorTagFor(const QString &generator); + /// Empty when unset; the caller disables the Sync button in that case. QString syncCommand() const { return m_syncCommand; } @@ -412,6 +420,13 @@ public: /// the same fixed string on every card rather than failing visibly. QString dateFormat() const { return m_dateFormat; } + /// Extra subject prefixes marking a forward the user RECEIVED (item 68). + /// + /// Added to the built-in table in composecontext.cpp, never replacing it, + /// so a user adding a locale keeps the measured English/German/Iberian/ + /// French spellings. Bare words, no colon. + QStringList forwardPrefixes() const { return m_forwardPrefixes; } + /// Interface language, or empty to follow the environment. /// /// A locale name, short ("it") or full ("it_IT"); Qt resolves the short @@ -536,6 +551,7 @@ private: int m_toolbarIconSize = 24; QString m_notmuchConfig; QString m_dateFormat; + QStringList m_forwardPrefixes; QString m_language; qreal m_messageZoom = 1.0; bool m_completionOnFocus = false; diff --git a/src/mainwindow.cpp b/src/mainwindow.cpp index 89c01eb..9166588 100644 --- a/src/mainwindow.cpp +++ b/src/mainwindow.cpp @@ -825,6 +825,7 @@ void MainWindow::buildUi() m_model = new ThreadListModel(this); m_model->setTagColors(&m_tagColors); m_model->setDateFormat(m_config.dateFormat()); + m_model->setForwardPrefixes(m_config.forwardPrefixes()); // ThreadListView, not a plain QTableView: it paints the row-wide tag // strip under each row's cells, which no delegate can do because a // delegate is confined to one column's rectangle. @@ -1071,6 +1072,11 @@ void MainWindow::openComposerFor(const MessageRef &ref, context.kind = kind; context.originalPath = originalPath; + // Item 68. Set for a forward as well as a reply, which is why it is not + // inReplyTo: that header is deliberately omitted from a forward below, and + // the P flag still belongs on the message that was forwarded. + context.sourceMessageId = original.messageId; + const bool replyAll = kind == ComposeContext::Kind::ReplyAll; const bool forwarding = kind == ComposeContext::Kind::Forward; @@ -1166,6 +1172,26 @@ void MainWindow::openComposer(const ComposeContext &context) connect(composer, &ComposeWindow::draftRemoved, m_worker, &NotmuchWorker::removeIndexedFile); + // Item 68. The R and P Maildir flags, on the message the send answered. + // + // sendMessageTagChange, NOT tagSelected: this deliberately does not go on + // the undo stack, for the reason auto mark-read does not (see + // markCurrentThreadRead). The flag records a fact the user brought about + // by sending, and the send itself cannot be undone, so offering Ctrl+Z to + // retract only the flag would leave the two disagreeing. Removing the tag + // by hand still works. + // + // Message-scoped: the message answered, never its thread. + connect(composer, &ComposeWindow::sourceMessageAnswered, this, + [this](const QString &messageId, const QString &tag) { + if (messageId.isEmpty() || tag.isEmpty()) + return; + sendMessageTagChange({ messageId }, { tag }, {}, + tag == QStringLiteral("passed") + ? tr("Mark forwarded") + : tr("Mark replied")); + }); + composer->show(); } @@ -1555,9 +1581,23 @@ void MainWindow::registerActions() }); addAction(QStringLiteral("delete"), tr("&Delete"), tr("Add or remove the deleted tag"), [this]() { - // A toggle, like toggle_unread: pressing Delete twice is the natural - // way to say "no, put it back", and adding a tag that is already there - // is a no-op the user cannot see. + // Two directions, and since 2026-08-26 only ONE of them is reachable + // on ordinary mail. + // + // This began as item 16's toggle: pressing Delete twice was how a user + // said "no, put it back", and it existed because the deleted row + // STAYED in the view, tinted, with nothing else to press. Delete now + // strips `inbox` and the row leaves the view immediately, so there is + // no second press to make and the mitigation is not needed; Ctrl+Z + // retracts, and Restore in the trash is the deliberate route. + // + // The undelete branch survives because it is NOT dead: stranded mail + // (tagged `deleted`, outside any trash folder, from a version before + // Delete moved files) is the one place `allDeleted` is still true + // where Delete is visible at all, since item 168 hides the action + // whenever every selected row is already in a trash folder. That is + // what `cleanup_stranded` sends the user to, telling them to select + // what should go and press Delete. // // One direction for the WHOLE selection. Toggling each thread // independently would leave one keystroke with the selection in two @@ -3272,6 +3312,28 @@ void MainWindow::runQuery(FlatResult flat, AccountScope scope) QString query = m_queryEdit->text().trimmed(); + // Whether this is the trash view, which suppresses the doomed fill: every + // row there is deleted, so the crimson says nothing and only costs + // legibility. + // + // Derived from the QUERY rather than from which button was clicked, so a + // hand-typed or edited trash query gets the same treatment as the button, + // and set on EVERY run for the reason flat mode is: a flag left standing + // would paint the next view's genuinely doomed rows plain. + // + // Compared against the trash filter resolved in the CURRENT account scope, + // which is what runFilter() put in the bar. matchNothingQuery() is + // excluded because it is a real string that compares equal to itself, so + // an account with no trash folder would otherwise match it. + { + const QString trashQuery = m_config.resolvedQuery( + Config::builtinFilter(QStringLiteral("trash")), + m_accountBox->currentData().toString()); + m_model->setTrashView(!query.isEmpty() + && trashQuery != Config::matchNothingQuery() + && query == trashQuery); + } + // A built-in filter arrives already resolved in the selected account's // scope, because a generator has to be asked for the account's own query // rather than have its all-accounts query wrapped. Scoping again here would @@ -5459,9 +5521,15 @@ void MainWindow::trashMessages(const QStringList &messageIds, // to touch, and the difference is who is acting: the hook tags // arriving mail unattended, while this is an explicit gesture on a // message in front of the user. + // `inbox` goes with it too. Without that a message deleted FROM the + // inbox keeps the tag the Inbox filter matches on, so it stays in that + // view after being thrown away: measured 2026-08-26 on the user's own + // mail, where it was the only message ever deleted from an inbox and + // therefore the only one that could show it. Restore does not depend + // on it surviving, since `deleted-from:` carries the origin. sendMove(it.value(), it.key(), { QStringLiteral("deleted"), kOriginTagPlaceholder() }, - { QStringLiteral("unread") }, + { QStringLiteral("unread"), QStringLiteral("inbox") }, tr("Delete"), false, wholeThreadIds); } @@ -5745,12 +5813,40 @@ void MainWindow::restoreResolvedMessages(const QStringList &messageIds, QStringList remove{ QStringLiteral("deleted") }; if (!origin.isEmpty()) remove.append(origin); - sendMove(it.value(), it.key(), {}, remove, tr("Restore")); + + // `inbox` comes back when, and only when, the message is going back + // to an inbox. Delete strips it (so a deleted message leaves the + // Inbox view), which makes restoring it the other half of that + // change: without this a restored message sits in the inbox FOLDER + // carrying no `inbox` TAG, invisible to the view it was returned to + // until the next hook run. Undo is unaffected either way, since + // TagChange::inverted() gives back exactly what the move removed. + // + // Judged on the DESTINATION folder rather than on the origin tag's + // text, so an account whose inbox is named something else is right for + // the same reason inboxFolderFor() exists. The key is + // `<maildir>/<folder>`, and the account is resolved back from it + // rather than captured above, where it belongs to the per-message loop + // and is out of scope here. + QStringList add; + const QString destMaildir = it.key().section(QLatin1Char('/'), 0, 0); + for (const Account &candidate : m_config.accounts()) { + if (candidate.maildir != destMaildir) + continue; + if (origin.compare(candidate.inboxFolder(), Qt::CaseInsensitive) == 0) + add.append(QStringLiteral("inbox")); + break; + } + + sendMove(it.value(), it.key(), add, remove, tr("Restore")); } for (auto it = byInbox.cbegin(); it != byInbox.cend(); ++it) { - sendMove(it.value(), it.key(), {}, { QStringLiteral("deleted") }, - tr("Restore")); + // This branch IS the inbox by construction: it is the fallback for a + // message with no origin tag, and the folder it names is the + // account's own inbox. So the tag always comes with it. + sendMove(it.value(), it.key(), { QStringLiteral("inbox") }, + { QStringLiteral("deleted") }, tr("Restore")); } if (!byInbox.isEmpty()) { @@ -6092,6 +6188,24 @@ void MainWindow::sendMove(const QStringList &messageIds, m_model->applyMessageTagChange(messageId, displayAdd, displayRemove); } + // A row that no longer belongs in the view LEAVES it, rather than sitting + // there repainted until the next query. Delete strips `inbox`, so in the + // Inbox view the message it stripped it from stops matching, and leaving + // it was the defect: a deleted message stayed in the inbox across + // restarts, since the tag really was gone from the display and really was + // still what the query asked for. + // + // Guarded on the VIEW's own tag, resolved from the query rather than + // assumed: a plain `tag:<x>` query is the only shape whose membership one + // tag decides. A path query (Trash, Sent, Drafts) is unaffected by a tag + // going away, and an arbitrary query the user typed cannot be reasoned + // about at all, so both are left alone and refresh at the next sync. + // Without that guard, deleting from an `id:` view would empty the list. + if (const QString viewTag = viewFilterTag(); + !viewTag.isEmpty() && displayRemove.contains(viewTag)) { + m_model->removeThreadsWithoutTag(viewTag); + } + // What to tag once the move is CONFIRMED. Tagging now would leave a // message marked deleted in a folder it never left if the rename failed. // @@ -6110,6 +6224,38 @@ void MainWindow::sendMove(const QStringList &messageIds, Q_ARG(QString, destFolder)); } +QString MainWindow::viewFilterTag() const +{ + const QString query = m_queryEdit->text().trimmed(); + if (query.isEmpty()) + return {}; + + const QString accountKey = m_accountBox->currentData().toString(); + + // The three tag-backed built-ins, matched against the query RESOLVED in + // the current account scope, which is what runFilter() put in the bar. The + // comparison is on the generated string rather than on the button's + // checked state, so a query the user edited by hand into the same thing + // behaves identically, and a label translated into another locale cannot + // change the answer. + for (const QString &generator : { QStringLiteral("unread"), + QStringLiteral("inbox"), + QStringLiteral("flagged") }) { + const SavedQuery filter = Config::builtinFilter(generator); + const QString resolved = m_config.resolvedQuery(filter, accountKey); + if (resolved == Config::matchNothingQuery()) + continue; + if (resolved == query) { + // The TAG, not the generator: "flagged" happens to match its tag + // and "inbox" and "unread" do too, but the generator is a filter + // identity and the tag is what a message carries. + return Config::generatorTagFor(generator); + } + } + + return {}; +} + void MainWindow::onMessagesMoved(const QMap<QString, QString> &originByMessageId, const QString &destFolder) { diff --git a/src/mainwindow.h b/src/mainwindow.h index a5a8c31..cb8cec4 100644 --- a/src/mainwindow.h +++ b/src/mainwindow.h @@ -1104,6 +1104,16 @@ private: /// Confirms a move: applies the tags the move was asked to carry, with the /// origin placeholder resolved per message. + /// The single tag the CURRENT view's membership depends on, or empty. + /// + /// Only the three plain `tag:` filters (Unread, Inbox, Important) have + /// one: their query is exactly that tag, so a message losing it stops + /// belonging. Trash, Sent and Drafts are PATH queries, where a tag change + /// decides nothing, and a hand-typed query is not reasoned about at all. + /// Both answer empty, which is what keeps an optimistic row removal from + /// firing in a view it cannot judge. + QString viewFilterTag() const; + void onMessagesMoved(const QMap<QString, QString> &originByMessageId, const QString &destFolder); diff --git a/src/marks.cpp b/src/marks.cpp index 8777f84..d205ea3 100644 --- a/src/marks.cpp +++ b/src/marks.cpp @@ -57,6 +57,13 @@ QByteArray svg(Mark mark) "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 6.8,2.2 V 5.0 " "H 8.7 C 11.9,5.0 14.2,7.4 14.2,10.8 V 13.8 a 0.9,0.9 0 0 1 -1.75,0.28 C " "11.8,12.1 10.4,10.9 8.7,10.9 H 6.8 V 13.7 L 1.0,7.95 Z\"/> </svg>"); + case Mark::ReceivedForward: + return QByteArray( + "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " + "viewBox=\"0 0 16 16\"> <path fill=\"currentColor\" d=\"M 2.0,2.6 a " + "0.95,0.95 0 0 1 1.9,0 V 6.4 C 3.9,8.1 5.2,9.4 6.9,9.4 H 9.6 V 6.6 L " + "15.0,11.0 L 9.6,15.4 V 12.6 H 6.9 C 3.5,12.6 2.0,10.3 2.0,7.4 Z\"/> " + "</svg>"); case Mark::ExpanderCollapsed: return QByteArray( "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"16\" height=\"16\" " diff --git a/src/marks.h b/src/marks.h index 823c129..e266924 100644 --- a/src/marks.h +++ b/src/marks.h @@ -52,6 +52,13 @@ enum class Mark { Flagged, Passed, Replied, + /// Someone forwarded this message TO the user. + /// + /// Not a Maildir flag and not a tag: derived from the subject line at paint + /// time, so it stores nothing and reaches no server. Passed means "I + /// forwarded this", which is a different fact about a different person, + /// which is why this is its own mark rather than a reuse. + ReceivedForward, ExpanderCollapsed, ExpanderExpanded, }; diff --git a/src/threadlistmodel.cpp b/src/threadlistmodel.cpp index 6162a5f..fcd8e4f 100644 --- a/src/threadlistmodel.cpp +++ b/src/threadlistmodel.cpp @@ -18,6 +18,8 @@ #include "threadlistmodel.h" +#include "composecontext.h" + #include <QSet> #include <QBrush> @@ -177,6 +179,65 @@ void ThreadListModel::setFlatMode(bool flat) endResetModel(); } +void ThreadListModel::setTrashView(bool trash) +{ + if (m_trashView == trash) + return; + + m_trashView = trash; + + // A repaint, NOT a reset: this changes two colour roles and nothing about + // the shape of the tree, so unlike setFlatMode() there are no child rows + // to invalidate and a reset would collapse every expanded thread for a + // change of paint. Emitted over the whole list including children, since + // the message-row branch reads the same flag. + if (m_threads.isEmpty()) + return; + const QVector<int> roles{ Qt::BackgroundRole, Qt::ForegroundRole }; + emit dataChanged(index(0, 0, QModelIndex()), + index(m_threads.size() - 1, 0, QModelIndex()), roles); + for (int row = 0; row < m_threads.size(); ++row) { + const QModelIndex parent = index(row, 0, QModelIndex()); + const int children = rowCount(parent); + if (children > 0) { + emit dataChanged(index(0, 0, parent), + index(children - 1, 0, parent), roles); + } + } +} + +void ThreadListModel::removeThreadsWithoutTag(const QString &tag) +{ + if (tag.isEmpty() || m_threads.isEmpty()) + return; + + // The tags a row is judged on are the ones its CARD draws: the loaded + // message's own when there is one, the thread's union otherwise. That is + // the same substitution data() makes for a thread row, and using the + // summary alone would keep a row whose displayed message lost the tag + // while a sibling still carries it. + const auto keeps = [&tag](const ThreadNode &node) { + if (!node.first.messageId.isEmpty()) + return node.first.tags.contains(tag); + return node.summary.tags.contains(tag); + }; + + // Backwards, in contiguous runs, exactly as reconcile() removes: each + // beginRemoveRows renumbers everything after it, so walking forwards + // removes the wrong rows after the first deletion. + for (int row = m_threads.size() - 1; row >= 0; --row) { + if (keeps(m_threads.at(row))) + continue; + int first = row; + while (first > 0 && !keeps(m_threads.at(first - 1))) + --first; + beginRemoveRows({}, first, row); + m_threads.remove(first, row - first + 1); + endRemoveRows(); + row = first; + } +} + int ThreadListModel::rowCount(const QModelIndex &parent) const { if (!parent.isValid()) @@ -338,6 +399,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return node.isFlagged(); case IsPassedRole: return node.isPassed(); + case IsReceivedForwardRole: + return ComposeContextBuilder::subjectIsForwarded(node.subject, + m_forwardPrefixes); case IsRepliedRole: return node.isReplied(); case ReplyCountRole: @@ -352,9 +416,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // thread row does. Without this branch a message-scoped Delete // repainted a reply identically to an undeleted one, so the // pending count moved and nothing on screen did. - if (node.isDoomed()) + // Suppressed in the trash view, exactly as on a thread row: see + // the comment there. Both branches must agree, or an expanded + // thread in the trash paints its replies crimson under an + // untinted root. + if (node.isDoomed() + && !(m_trashView && node.isDeleted() && !node.isSpam())) { return QBrush(node.isDeleted() ? deletedColour() : spamColour()); + } // Tinted, so an expanded thread reads as one block rather than as // more table rows. Applied per cell here; ThreadListView fills the @@ -401,8 +471,16 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // read colour is mixed toward the BACKGROUND, so leaving it here // would compute a grey against the pane's base and then paint it // over red. - if (node.isDoomed()) + // + // Tied to the FILL, not to isDoomed(): where the fill is + // suppressed in the trash view there is no red to sit on, and + // white text would land on the ordinary background unreadable. + // The strike-out below is deliberately NOT suppressed, since it + // is the cue that survives without colour at all. + if (node.isDoomed() + && !(m_trashView && node.isDeleted() && !node.isSpam())) { return QBrush(QColor(Qt::white)); + } // Dimmed whether read or not, for the same reason as the font: a // reply is subordinate content. An unread one is left undimmed so @@ -611,6 +689,9 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const return thread.isFlagged(); case IsPassedRole: return thread.isPassed(); + case IsReceivedForwardRole: + return ComposeContextBuilder::subjectIsForwarded(thread.subject, + m_forwardPrefixes); case IsRepliedRole: return thread.isReplied(); case ReplyCountRole: @@ -633,7 +714,15 @@ QVariant ThreadListModel::data(const QModelIndex &index, int role) const // whole row: a cue on a single column disappears as soon as that column // scrolls out of view, which is exactly how the tag change used to go // unnoticed. - if (thread.isDoomed()) { + // + // In the TRASH view the deleted fill is suppressed: every row there is + // deleted, so a list painted entirely crimson tells the user nothing they + // did not ask for by opening the trash, and costs the legibility the fill + // borrows. Only `deleted` is suppressed; a SPAM row keeps its tint, since + // "this is junk" is still news in a folder that only promises "this is + // thrown away". + const bool suppressed = m_trashView && thread.isDeleted() && !thread.isSpam(); + if (thread.isDoomed() && !suppressed) { if (role == Qt::BackgroundRole) return QBrush(thread.isDeleted() ? deletedColour() : spamColour()); if (role == Qt::ForegroundRole) diff --git a/src/threadlistmodel.h b/src/threadlistmodel.h index 717537c..2e56328 100644 --- a/src/threadlistmodel.h +++ b/src/threadlistmodel.h @@ -135,6 +135,12 @@ public: /// Item 69 draws this as a mark where it used to read as the word /// "passed" in the tag strip. IsPassedRole, + /// True when the SUBJECT reads as a forward someone sent the user. + /// + /// Item 68. Derived from the subject at query time, not from a tag or + /// a Maildir flag: `passed` means "I forwarded this", which is a + /// different fact. Nothing is stored and nothing reaches the server. + IsReceivedForwardRole, /// bool; the message was replied to, from the Maildir "R" flag. IsRepliedRole, @@ -187,6 +193,16 @@ public: /// The pattern DateFormatRole answers with. Empty means the system format. void setDateFormat(const QString &format) { m_dateFormat = format; } + /// Extra subject prefixes counting as a received forward (item 68). + /// + /// Pushed in from the config exactly as setDateFormat() is, rather than + /// giving the model a Config: both are display values the window already + /// holds, and the model draws rather than resolves. + void setForwardPrefixes(const QStringList &prefixes) + { + m_forwardPrefixes = prefixes; + } + /// One row per thread, with no expander and no reply count. /// /// For the Sent view, where a thread is the wrong unit: the user's model of @@ -203,6 +219,29 @@ public: /// The children are not discarded, only hidden. Leaving flat mode restores /// the tree without reloading anything. void setFlatMode(bool flat); + + /// Whether the list is showing the trash view. + /// + /// The doomed fill exists to tell the user a message is on its way out of + /// a view it is still sitting in. In the trash that is redundant: every + /// row is deleted, and a list painted entirely crimson says nothing while + /// costing legibility. Set on EVERY query run, like flat mode, so it + /// cannot leak into the next view. + void setTrashView(bool trash); + + /// Drops any top-level row whose message no longer carries \p tag. + /// + /// The optimistic counterpart to a row simply vanishing at the next query. + /// Delete strips `inbox`, and in the Inbox view the row it stripped it + /// from stops belonging there; leaving it until the next sync is what made + /// a deleted message sit in the inbox looking undeleted. + /// + /// Top-level rows ONLY, and deliberately: a reply that no longer matches + /// still belongs to the conversation the user has open, and removing it + /// would collapse a thread under the reader's hands. \p tag is the tag the + /// CURRENT VIEW requires, so a caller passes what the query filters on and + /// nothing else. + void removeThreadsWithoutTag(const QString &tag); bool flatMode() const { return m_flatMode; } QModelIndex index(int row, int column, @@ -414,5 +453,7 @@ private: QVector<ThreadNode> m_threads; const TagColors *m_tagColors = nullptr; QString m_dateFormat; + QStringList m_forwardPrefixes; bool m_flatMode = false; + bool m_trashView = false; }; diff --git a/src/types.h b/src/types.h index 7464586..cf2411d 100644 --- a/src/types.h +++ b/src/types.h @@ -252,7 +252,15 @@ struct ComposeContext QString accountKey; ///< Which account sends. Plain data here; the resolution rules live with whatever builds this context. Kind kind = Kind::New; QString originalPath; ///< The .eml being replied to or forwarded. Empty for New. - QString inReplyTo; ///< Message-ID of the original. + QString inReplyTo; ///< Message-ID of the original. EMPTY for a Forward: carrying In-Reply-To would file the forward under the thread it left, in the recipient's client. + + /// Message-ID of the message being answered, for flagging it afterwards. + /// + /// Item 68. Separate from inReplyTo because that is a THREADING header and + /// is deliberately empty on a Forward, while the P flag still has to land + /// on the message that was forwarded. Set for Reply, ReplyAll and Forward; + /// empty for New and for a resumed Draft. + QString sourceMessageId; QStringList references; ///< The original's References plus its Message-ID. QStringList to; ///< Pre-filled, the user's own addresses already stripped. QStringList cc; diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp index fccec87..bea390d 100644 --- a/tests/test_composecontext.cpp +++ b/tests/test_composecontext.cpp @@ -81,6 +81,8 @@ private slots: void aSingleLetterBeforeAColonIsNotAPrefix(); // Account resolution. + void aReceivedForwardIsRecognisedFromItsSubject(); + void configuredForwardPrefixesExtendTheBuiltInTable(); void theReplyAccountComesFromTheMessagesMaildir(); void anAccountIsNotMatchedByAPrefixOfItsMaildir(); void anAmbiguousMessagePrefersTheMatchingRecipient(); @@ -808,6 +810,81 @@ void TestComposeContext::aSingleLetterBeforeAColonIsNotAPrefix() QStringLiteral("Fwd: F: results")); } +void TestComposeContext::aReceivedForwardIsRecognisedFromItsSubject() +{ + using ComposeContextBuilder::subjectIsForwarded; + + // Item 68. The display predicate behind the received-forward mark. It + // shares forwardSubject()'s prefix table deliberately, so the two cannot + // disagree about what a forward looks like. + QVERIFY(subjectIsForwarded(QStringLiteral("Fwd: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("Fw: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("FWD: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("WG: Angebot"))); + QVERIFY(subjectIsForwarded(QStringLiteral("TR: document"))); + + // Anchored. "Fwd:" inside a subject is a quotation, not a marker, and the + // whole reason item 68's entry insisted on anchoring. + QVERIFY(!subjectIsForwarded(QStringLiteral("Notes fwd: budget"))); + QVERIFY(!subjectIsForwarded(QStringLiteral("budget"))); + QVERIFY(!subjectIsForwarded(QString())); + + // The single-letter spellings stay unrecognised here for exactly the + // reason forwardSubject() rejects them: "I: notes" is an ordinary subject. + QVERIFY(!subjectIsForwarded(QStringLiteral("I: notes"))); + QVERIFY(!subjectIsForwarded(QStringLiteral("F: results"))); + + // A reply to a forward is still a forward the user received, so the Re: + // chain is stripped first. Both orders, and a counted Outlook form. + QVERIFY(subjectIsForwarded(QStringLiteral("Re: Fwd: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("Re: Re: Fwd: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("Re[2]: Fwd: budget"))); + QVERIFY(subjectIsForwarded(QStringLiteral("AW: WG: Angebot"))); + + // A plain reply is not a forward, however deep the chain. + QVERIFY(!subjectIsForwarded(QStringLiteral("Re: budget"))); + QVERIFY(!subjectIsForwarded(QStringLiteral("Re: Re: Re: budget"))); +} + +void TestComposeContext::configuredForwardPrefixesExtendTheBuiltInTable() +{ + using ComposeContextBuilder::subjectIsForwarded; + + // Item 68. [general] forward_prefixes ADDS to the table rather than + // replacing it: a user adding Dutch must not lose English. + const QStringList dutch = { QStringLiteral("Doorst") }; + QVERIFY(subjectIsForwarded(QStringLiteral("Doorst: begroting"), dutch)); + QVERIFY(subjectIsForwarded(QStringLiteral("Fwd: budget"), dutch)); + + // Case-insensitive and counted forms, like the built-ins. + QVERIFY(subjectIsForwarded(QStringLiteral("DOORST: begroting"), dutch)); + QVERIFY(subjectIsForwarded(QStringLiteral("Doorst[2]: begroting"), dutch)); + QVERIFY(subjectIsForwarded(QStringLiteral("Re: Doorst: begroting"), dutch)); + + // An unconfigured spelling stays unrecognised, which is what makes the + // key worth having rather than the predicate matching anything. + QVERIFY(!subjectIsForwarded(QStringLiteral("Doorst: begroting"))); + + // Non-word entries are ignored per entry. Measured 2026-08-26: escaping + // alone already makes punctuation inert, so what the guard actually buys + // is that a configured "-" does not make "-: x" a forward, and a digit + // does not make "2: x" one. Neither is a marker any client emits. + QVERIFY(!subjectIsForwarded(QStringLiteral("-: x"), + { QStringLiteral("-") })); + QVERIFY(!subjectIsForwarded(QStringLiteral("2: x"), + { QStringLiteral("2") })); + + // An empty or blank entry contributes nothing rather than matching + // everything, which is the failure that would be silent and total. + const QStringList blank = { QString(), QStringLiteral(" ") }; + QVERIFY(!subjectIsForwarded(QStringLiteral("budget"), blank)); + QVERIFY(!subjectIsForwarded(QStringLiteral("anything at all"), blank)); + + // A configured "Re" must not turn every reply into a forward. + QVERIFY(!subjectIsForwarded(QStringLiteral("Re: budget"), + { QStringLiteral("Re") })); +} + // --------------------------------------------------------------------------- // Account resolution // --------------------------------------------------------------------------- diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp index f76ff70..2032793 100644 --- a/tests/test_mainwindow.cpp +++ b/tests/test_mainwindow.cpp @@ -124,6 +124,10 @@ public: /// Written only when non-empty, like trash: an account without one /// offers no Drafts filter and no Edit draft (items 138 and 153). QString drafts; + /// Where a sent copy is filed. Written only when non-empty; an + /// account without one sends and files nothing, which is a real + /// configuration rather than an error. + QString sent; }; /// Writes several accounts, for the compose cases. @@ -202,6 +206,8 @@ public: out << "send_command=" << account.sendCommand << "\n"; if (!account.drafts.isEmpty()) out << "drafts=" << account.drafts << "\n"; + if (!account.sent.isEmpty()) + out << "sent=" << account.sent << "\n"; } } file.close(); @@ -472,7 +478,7 @@ private slots: void twoDeletesToOneTrashBothGetTheirTags(); void deletingTwiceLeavesNoOriginTagBehind(); void undoOfADeleteRemovesTheOriginTagToo(); - void deletingAThreadRootTwiceRestoresItRatherThanRedeleting(); + void deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt(); void deleteThreadMovesEveryMessageAndRepaintsTheRootCard(); void aFolderNameWithASpaceSurvivesTheRoundTrip(); void deleteIsBoundToTheDeleteKey(); @@ -529,6 +535,11 @@ private slots: void anUnchangedMessageIsNotWrittenAgain(); void closingInsideTheDebounceStillSavesTheDraft(); void closingAfterASendWritesNoFurtherDraft(); + void aSendRemovesADraftMbsyncHasRenamed(); + void aForwardFlagsTheMessageItForwarded(); + void aReplyFlagsTheMessageItAnswered(); + void aResumedDraftFlagsNothing(); + void aForwardWritesThePassedTagToTheIndex(); void aCloseDuringTheCountdownIsRefused(); void aFailedSendKeepsTheTextThatFailedToGo(); void aSmallSizeLimitIsNotDescribedAsZeroMegabytes(); @@ -11286,7 +11297,7 @@ void TestMainWindow::undoOfADeleteRemovesTheOriginTagToo() 0); } -void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting() +void TestMainWindow::deletingAThreadRootRemovesItFromTheInboxAndUndoReturnsIt() { // The toggle asked a THREAD ROW about its thread's tags, which notmuch // gives as a UNION over the conversation. Delete the root of a @@ -11367,9 +11378,20 @@ void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting() "tag:deleted")), 0); - // Second press on the row as it stands, no re-query. - view->setCurrentIndex(model->index(0, 0, QModelIndex())); - window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); + // There is no second press to make any more, and that is the point. + // + // Item 16's double-press-to-undelete existed because the deleted row + // STAYED in the view with nothing else to act on. Since 2026-08-26 Delete + // strips `inbox` too, so in this `tag:inbox` view the row LEAVES: the + // mitigation is unreachable here because the thing it mitigated is gone. + // Confirmed with the user, who chose this over keeping the toggle. + // + // Undo is what retracts now, and it must put back BOTH halves: the file + // and the tag travelled in one TagChange precisely so one Ctrl+Z returns + // them together. + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 0, 15000); + + window.findChild<QAction *>(QStringLiteral("undo"))->trigger(); QTRY_VERIFY_WITH_TIMEOUT( folderHasMessageFile(root + QStringLiteral("/acct/inbox/cur"), stem) @@ -11382,6 +11404,16 @@ void TestMainWindow::deletingAThreadRootTwiceRestoresItRatherThanRedeleting() == 0, 15000); + // The tag half of the same undo. Asserted separately because the file + // moving back and `inbox` coming back are two different failures, and a + // restore that returns the file without the tag is invisible in the view + // it was returned to. + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, + QStringLiteral("id:troot@example.org and tag:inbox")) + == 1, + 15000); + // Asked of notmuch directly: a UI query reads 0 rows for the whole // interval before the worker answers, so an absence assertion through the // query bar passes against any state of the database. @@ -12171,9 +12203,17 @@ void TestMainWindow::twoDeletesToOneTrashBothGetTheirTags() // Both Deletes issued back to back, WITHOUT waiting for the first to be // confirmed. That is the whole point: waiting would serialise them and // the keyed table would have coped. + // + // Both take row 0, and that is not a typo. Delete strips `inbox`, and in + // this `tag:inbox` view the row it stripped it from LEAVES the list + // immediately, so what was row 1 becomes row 0 the moment the first + // Delete is triggered. Naming index(1, 0) here would select a row that no + // longer exists and the second message would never be deleted at all, + // which is exactly how this test failed when the removal was added. view->setCurrentIndex(model->index(0, 0, QModelIndex())); window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); - view->setCurrentIndex(model->index(1, 0, QModelIndex())); + QTRY_VERIFY_WITH_TIMEOUT(model->rowCount(QModelIndex()) == 1, 15000); + view->setCurrentIndex(model->index(0, 0, QModelIndex())); window.findChild<QAction *>(QStringLiteral("delete"))->trigger(); const QString root = backed.fixture().maildirPath(); @@ -14443,6 +14483,260 @@ void TestMainWindow::closingAfterASendWritesNoFurtherDraft() QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); } +void TestMainWindow::aSendRemovesADraftMbsyncHasRenamed() +{ + // Measured on the user's own mail, 2026-08-26: a forward was sent, the + // recipient got it, the sent copy was filed, and the draft STAYED in the + // Drafts view carrying the `D` flag. + // + // mbsync renames an uploaded draft to add its `,U=<uid>` infix while + // m_draftPath still holds the name DraftStore::write() returned, so + // QFile::remove() ran against a path that no longer existed and failed + // silently. Item 163 added MaildirName::resolveRenamed() for exactly this + // rename and wired it into the three READ sites; this is the WRITE site, + // and it was missed. + // + // closingAfterASendWritesNoFurtherDraft() already asserts the drafts + // folder is empty after a send and passed throughout, because its draft is + // never renamed. The rename is the whole defect, so it has to be in the + // fixture. + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.to = { QStringLiteral("someone@example.org") }; + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = + window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + body->setPlainText(QStringLiteral("Text that is about to be sent.")); + QVERIFY(window->saveDraftNow()); + QCOMPARE(fixture.draftCount(), 1); + + // Renamed exactly as mbsync renames it: the `,U=<uid>` infix goes before + // the `:2,` flag separator, so the stem the composer remembers is still a + // prefix of the real name and nothing but a directory scan can find it. + const QStringList before = + QDir(fixture.draftsCur(), {}, QDir::Name, QDir::Files).entryList(); + QCOMPARE(before.size(), 1); + const QString original = before.first(); + const int sep = original.indexOf(QStringLiteral(":2,")); + QVERIFY2(sep > 0, "the draft filename carries no :2, flag separator"); + const QString renamed = original.left(sep) + QStringLiteral(",U=7") + + original.mid(sep); + QVERIFY(QFile::rename(fixture.draftsCur() + QLatin1Char('/') + original, + fixture.draftsCur() + QLatin1Char('/') + renamed)); + QCOMPARE(fixture.draftCount(), 1); + + sendAction->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + // The sent copy proves the send actually completed, so an empty drafts + // folder below means the removal worked rather than that nothing ran. + const QString sentCur = + fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); + + QCOMPARE(fixture.draftCount(), 0); +} + +void TestMainWindow::aForwardFlagsTheMessageItForwarded() +{ + // Item 68. The signal that carries the P flag back to the source message. + // Asserted on the SIGNAL rather than on the tag, because the tag write is + // MainWindow's and needs a worker; what can go wrong here is the composer + // never emitting, which is what the user observed on 2026-08-26. + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Forward; + context.to = { QStringLiteral("someone@example.org") }; + + // Set for a forward as well as a reply, and deliberately NOT inReplyTo: + // that header is empty on a forward, so keying the emit on it made this + // half dead code that compiled and never fired. + context.sourceMessageId = QStringLiteral("original@example.org"); + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = + window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + QString flaggedId; + QString flaggedTag; + connect(window.data(), &ComposeWindow::sourceMessageAnswered, + [&](const QString &id, const QString &tag) { + flaggedId = id; + flaggedTag = tag; + }); + + body->setPlainText(QStringLiteral("Passing this on.")); + sendAction->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + // The sent copy proves the send completed, so an unset tag below is a + // missing emit rather than a send that never happened. + const QString sentCur = + fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); + + QCOMPARE(flaggedId, QStringLiteral("original@example.org")); + QCOMPARE(flaggedTag, QStringLiteral("passed")); +} + +void TestMainWindow::aReplyFlagsTheMessageItAnswered() +{ + // The other half of item 68, and the one the measurement said was ALSO + // missing: all 317 `replied` in the developer's index came from other + // clients, because nothing here had ever written the R flag. + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Reply; + context.to = { QStringLiteral("someone@example.org") }; + context.sourceMessageId = QStringLiteral("original@example.org"); + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = + window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + QString flaggedTag; + connect(window.data(), &ComposeWindow::sourceMessageAnswered, + [&](const QString &, const QString &tag) { flaggedTag = tag; }); + + body->setPlainText(QStringLiteral("Answering.")); + sendAction->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + QCOMPARE(flaggedTag, QStringLiteral("replied")); +} + +void TestMainWindow::aResumedDraftFlagsNothing() +{ + // Kind::Draft records how the FILE was opened, not what the user is + // doing, so a draft that began as a reply cannot be told from one that + // began as a new message. Flagging on it would set R from a guess, and + // maildir.synchronize_flags carries a wrong flag to the server. + ComposeFixture fixture; + QVERIFY(fixture.build(QStringLiteral("Drafts"), QStringLiteral("Sent"), + QStringLiteral("send_delay_ms=0"))); + + ComposeContext context = newContext(); + context.kind = ComposeContext::Kind::Draft; + context.to = { QStringLiteral("someone@example.org") }; + + // Present, and must still be ignored: this is the case a guard keyed only + // on the id being non-empty would get wrong. + context.sourceMessageId = QStringLiteral("original@example.org"); + + QPointer<ComposeWindow> window = + new ComposeWindow(context, fixture.config(), fixture.mailRoot()); + auto *body = window->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = + window->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + bool emitted = false; + connect(window.data(), &ComposeWindow::sourceMessageAnswered, + [&](const QString &, const QString &) { emitted = true; }); + + body->setPlainText(QStringLiteral("Finishing this off.")); + sendAction->trigger(); + QTRY_VERIFY_WITH_TIMEOUT(window.isNull(), 15000); + + const QString sentCur = + fixture.mailRoot() + QStringLiteral("/acct/Sent/cur"); + QCOMPARE(QDir(sentCur, {}, QDir::Name, QDir::Files).count(), 1u); + + QVERIFY2(!emitted, "a resumed draft flagged a message it cannot know it " + "was answering"); +} + +void TestMainWindow::aForwardWritesThePassedTagToTheIndex() +{ + // The END-TO-END half: aForwardFlagsTheMessageItForwarded() proves the + // composer emits, and this proves the tag actually reaches notmuch. The + // user forwarded real mail on 2026-08-26, the recipient got it, the sent + // copy was filed, and `tag:passed` never moved, so the gap is somewhere + // between the emit and the index and only a real worker can show which. + WorkerComposeFixture fixture; + QVERIFY2(fixture.seed({ { QStringLiteral("acct"), QStringLiteral("acct"), + QStringLiteral("Trash"), + QStringLiteral("/bin/true"), + QStringLiteral("you@example.org"), + QStringLiteral("Drafts"), + QStringLiteral("Sent") } }, + QStringLiteral("acct/inbox")), + qPrintable(fixture.backed.error())); + + MainWindow window(fixture.backed.config()); + QVERIFY(WorkerComposeFixture::selectTheMessage(window)); + + auto *forward = window.findChild<QAction *>(QStringLiteral("forward")); + QVERIFY(forward); + + // Forward is gated on a message being DISPLAYED, not merely selected: + // updateComposeActions() enables it from the pane. A disabled action's + // trigger() is a silent no-op, so asserting this is what stops the test + // measuring nothing. + QTRY_VERIFY_WITH_TIMEOUT(forward->isEnabled(), 15000); + forward->trigger(); + + // Forward is ASYNCHRONOUS: composeReply() asks the worker to load the + // message and the composer opens when that reply lands. Calling + // openComposerForTest() straight after the trigger returns before the + // round trip finishes, and the first version of this test did exactly + // that, then asserted on a composer whose kind was New and whose + // sourceMessageId was empty. Waiting on the COUNT is what makes the + // composer under test the one Forward opened. + QTRY_VERIFY_WITH_TIMEOUT(window.openComposerCount() == 1, 15000); + + // openComposersForTest(), NOT openComposerForTest(): the singular one + // OPENS a fresh Kind::New composer rather than returning an existing one, + // which is what the Compose action's tests want and is a trap here. The + // first version of this test used it, sent from the composer it had just + // created, and reported kind=0 with an empty sourceMessageId, reading + // exactly like the product defect it was written to reproduce. + const QList<ComposeWindow *> composers = window.openComposersForTest(); + QCOMPARE(composers.size(), 1); + ComposeWindow *composer = composers.first(); + QVERIFY2(composer, "Forward opened no composer"); + + auto *body = composer->findChild<QPlainTextEdit *>(QStringLiteral("body")); + auto *sendAction = + composer->findChild<QAction *>(QStringLiteral("compose_send")); + QVERIFY(body && sendAction); + + auto *to = composer->findChild<QLineEdit *>(QStringLiteral("to")); + QVERIFY(to); + to->setText(QStringLiteral("someone@example.org")); + body->setPlainText(QStringLiteral("Passing this on.")); + + sendAction->trigger(); + + // The tag lands through the worker, so this waits on the DATABASE rather + // than on a signal: the whole question is whether the write arrives. + const QString cfg = fixture.backed.fixture().configPath(); + QTRY_VERIFY_WITH_TIMEOUT( + notmuchCount(cfg, QStringLiteral("id:compose1@example.org and " + "tag:passed")) == 1, + 15000); +} + void TestMainWindow::aCloseDuringTheCountdownIsRefused() { ComposeFixture fixture; diff --git a/tests/test_threadlistmodel.cpp b/tests/test_threadlistmodel.cpp index 811b3e3..593a777 100644 --- a/tests/test_threadlistmodel.cpp +++ b/tests/test_threadlistmodel.cpp @@ -27,6 +27,9 @@ class TestThreadListModel : public QObject { Q_OBJECT private slots: + void aRowLeavesTheViewWhenItLosesTheViewsTag(); + void rowsLosingTheTagAreRemovedInOneContiguousRun(); + void theTrashViewDrawsNoDoomedFill(); void messageNodeHoldsDisplayFacts(); void rootRowsSurviveTheTreeConversion(); void repliesBecomeChildRowsUnderTheirThread(); @@ -2165,5 +2168,99 @@ void TestThreadListModel::recipientsReplaceTheSenderWhenPresent() QStringLiteral("You")); } +void TestThreadListModel::aRowLeavesTheViewWhenItLosesTheViewsTag() +{ + ThreadListModel model; + ThreadSummary a = makeThread(QStringLiteral("t1"), QStringLiteral("Keep")); + a.firstMessageId = QStringLiteral("m1"); + ThreadSummary b = makeThread(QStringLiteral("t2"), QStringLiteral("Drop")); + b.firstMessageId = QStringLiteral("m2"); + ThreadSummary c = makeThread(QStringLiteral("t3"), QStringLiteral("Keep2")); + c.firstMessageId = QStringLiteral("m3"); + model.appendBatch({ a, b, c }); + QCOMPARE(model.rowCount(), 3); + + // The middle row loses `inbox`, as Delete strips it. Middle deliberately: + // a removal at either end can be right by accident while the index + // arithmetic is wrong. + model.applyMessageTagChange(QStringLiteral("m2"), {}, + { QStringLiteral("inbox") }); + model.removeThreadsWithoutTag(QStringLiteral("inbox")); + + QCOMPARE(model.rowCount(), 2); + QCOMPARE(model.index(0, 0, QModelIndex()) + .data(ThreadListModel::SubjectRole).toString(), + QStringLiteral("Keep")); + QCOMPARE(model.index(1, 0, QModelIndex()) + .data(ThreadListModel::SubjectRole).toString(), + QStringLiteral("Keep2")); +} + +void TestThreadListModel::rowsLosingTheTagAreRemovedInOneContiguousRun() +{ + ThreadListModel model; + QList<ThreadSummary> batch; + for (int i = 1; i <= 5; ++i) { + ThreadSummary t = makeThread(QStringLiteral("t%1").arg(i), + QStringLiteral("S%1").arg(i)); + t.firstMessageId = QStringLiteral("m%1").arg(i); + batch.append(t); + } + model.appendBatch(batch); + + // Three adjacent rows go at once, which is the case a backwards walk in + // runs handles and a naive forward loop gets wrong by renumbering. + for (const QString &id : { QStringLiteral("m2"), QStringLiteral("m3"), + QStringLiteral("m4") }) { + model.applyMessageTagChange(id, {}, { QStringLiteral("inbox") }); + } + model.removeThreadsWithoutTag(QStringLiteral("inbox")); + + QCOMPARE(model.rowCount(), 2); + QCOMPARE(model.index(0, 0, QModelIndex()) + .data(ThreadListModel::SubjectRole).toString(), + QStringLiteral("S1")); + QCOMPARE(model.index(1, 0, QModelIndex()) + .data(ThreadListModel::SubjectRole).toString(), + QStringLiteral("S5")); +} + +void TestThreadListModel::theTrashViewDrawsNoDoomedFill() +{ + ThreadListModel model; + ThreadSummary deleted = makeThread(QStringLiteral("t1"), + QStringLiteral("Thrown away")); + deleted.tags = QStringList{ QStringLiteral("deleted") }; + ThreadSummary spam = makeThread(QStringLiteral("t2"), + QStringLiteral("Junk")); + spam.tags = QStringList{ QStringLiteral("deleted"), QStringLiteral("spam") }; + model.appendBatch({ deleted, spam }); + + const QModelIndex first = model.index(0, 0, QModelIndex()); + const QModelIndex second = model.index(1, 0, QModelIndex()); + + // Outside the trash both are filled, which is the guard proving the + // assertion below can fail. + QVERIFY(first.data(Qt::BackgroundRole).isValid()); + QVERIFY(second.data(Qt::BackgroundRole).isValid()); + + model.setTrashView(true); + + // A plainly deleted row loses the fill AND the white text that only reads + // against it; the strike-out is what still says deleted, and is asserted + // by the font role rather than by colour. + QVERIFY(!first.data(Qt::BackgroundRole).isValid()); + QVERIFY(first.data(Qt::FontRole).value<QFont>().strikeOut()); + + // A spam row keeps its tint: the trash promises "thrown away", not + // "harmless". + QVERIFY(second.data(Qt::BackgroundRole).isValid()); + + // And the flag does not stick: leaving the trash restores the fill, which + // is the leak the setter's comment warns about. + model.setTrashView(false); + QVERIFY(first.data(Qt::BackgroundRole).isValid()); +} + QTEST_MAIN(TestThreadListModel) #include "test_threadlistmodel.moc" diff --git a/translations/qtmaildir_it_IT.ts b/translations/qtmaildir_it_IT.ts index 22f8485..0d2c36f 100644 --- a/translations/qtmaildir_it_IT.ts +++ b/translations/qtmaildir_it_IT.ts @@ -238,6 +238,10 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation> <translation>Il formato data '%1' non contiene campi di data o ora; verrà usato il formato di sistema.</translation> </message> <message> + <source>Forward prefix '%1' should be written without its colon; ignoring it.</source> + <translation>Il prefisso di inoltro '%1' va scritto senza i due punti; verrà ignorato.</translation> + </message> + <message> <source>Unknown sync_on_exit '%1'; expected ask, always or never. Using ask.</source> <translation>Valore sconosciuto per sync_on_exit '%1'; sono ammessi ask, always o never. Verrà usato ask.</translation> </message> @@ -751,6 +755,14 @@ Il messaggio È stato inviato. Non inviarlo di nuovo.</translation> <translation>La radice della Maildir non è ancora nota</translation> </message> <message> + <source>Mark forwarded</source> + <translation>Segna come inoltrato</translation> + </message> + <message> + <source>Mark replied</source> + <translation>Segna come risposto</translation> + </message> + <message> <source>That message's file could not be found</source> <translation>Impossibile trovare il file di quel messaggio</translation> </message> |
