aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md44
-rw-r--r--README.md11
-rw-r--r--assets/icons/marks/received-forward.svg20
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md116
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md58
-rw-r--r--src/carddelegate.cpp3
-rw-r--r--src/cardlayout.cpp1
-rw-r--r--src/cardlayout.h6
-rw-r--r--src/composecontext.cpp52
-rw-r--r--src/composecontext.h20
-rw-r--r--src/composewindow.cpp56
-rw-r--r--src/composewindow.h12
-rw-r--r--src/config.cpp32
-rw-r--r--src/config.h16
-rw-r--r--src/mainwindow.cpp160
-rw-r--r--src/mainwindow.h10
-rw-r--r--src/marks.cpp7
-rw-r--r--src/marks.h7
-rw-r--r--src/threadlistmodel.cpp95
-rw-r--r--src/threadlistmodel.h41
-rw-r--r--src/types.h10
-rw-r--r--tests/test_composecontext.cpp77
-rw-r--r--tests/test_mainwindow.cpp306
-rw-r--r--tests/test_threadlistmodel.cpp97
-rw-r--r--translations/qtmaildir_it_IT.ts12
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`
diff --git a/README.md b/README.md
index 5b10332..bbb423e 100644
--- a/README.md
+++ b/README.md
@@ -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 &apos;%1&apos; non contiene campi di data o ora; verrà usato il formato di sistema.</translation>
</message>
<message>
+ <source>Forward prefix &apos;%1&apos; should be written without its colon; ignoring it.</source>
+ <translation>Il prefisso di inoltro &apos;%1&apos; va scritto senza i due punti; verrà ignorato.</translation>
+ </message>
+ <message>
<source>Unknown sync_on_exit &apos;%1&apos;; expected ask, always or never. Using ask.</source>
<translation>Valore sconosciuto per sync_on_exit &apos;%1&apos;; 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&apos;s file could not be found</source>
<translation>Impossibile trovare il file di quel messaggio</translation>
</message>