aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--CHANGELOG.md7
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability-closed.md83
-rw-r--r--docs/superpowers/plans/2026-08-03-post-0.1.0-usability.md106
-rw-r--r--src/composewindow.cpp9
-rw-r--r--tests/test_composewindow.cpp54
-rw-r--r--tests/test_mainwindow.cpp8
6 files changed, 220 insertions, 47 deletions
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 28e1571..69b09b9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -96,6 +96,13 @@ point at which they are stable.
### Fixed
+- **A draft you wrote was marked unread.** Drafts were written to disk without
+ the Maildir "seen" flag, and notmuch tags anything without it `unread`, so a
+ draft you had just typed appeared in the Unread view. It corrected itself
+ the next time that folder synced, which is why it seemed to happen only
+ sometimes: only the newest draft, in a folder that had not synced since,
+ showed it.
+
- **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.
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 7261bd9..fe212e2 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
@@ -8708,3 +8708,86 @@ Read-only, per the constraint above. The dialog's height is sized to its
content; that is a hand test, since the offscreen platform returns an
identical frame either way.
+
+## 169. A card shows the account only as a bar, with no fade and no avatar
+
+**Observed (user, from the notes):** "the left border of a card expresses the
+account the mail belongs to. the background color of the card should fade left
+to right from the account color to the current background color we are using (or
+to transparent to work both in light and dark themes). On the left we should
+leave room for an account avatar (a squircle), for now it could be extracted
+from the sender name "From: john doe" becomes "JD" in the avatar. As soon as we
+include khard (or some other vcard provider/manager) we will switch to images if
+the corresponding vCard has one."
+
+**Cause (verified in the code):** not a defect. Half of it shipped. The account
+colour is drawn as a solid bar down the left edge, `CardLayout::accentRect`
+placed by `CardLayout`, filled by `CardDelegate::paint()` with
+`CardDelegate::accentLineColour()`. There is no gradient anywhere on a card, and
+nothing draws an avatar: `CardLayout` reserves no rect for one, so the geometry
+would have to grow before the painting could.
+
+**Approach.** Two separable pieces, and the avatar is the one that changes the
+layout.
+
+- The fade is a `QLinearGradient` fill over the card rect, from the accent
+ colour to the pane's background. `accentLineColour()` already records why
+ blending toward the background is wrong for a CHIP; a card's background is
+ exactly where such a blend belongs, so the constraint does not carry over.
+ Both themes come free if the far stop is the palette's own base rather than
+ a literal.
+- The avatar needs a rect in `CardLayout`, which is where it becomes testable
+ without a painter, and it shifts `contentLeft` for every card. The initials
+ come from the display name already carried on the summary; a sender with no
+ display name (an address only) needs an answer before this is built.
+
+**Constraints.**
+
+- The vCard half is blocked on item 72, which is itself unspecified. Build the
+ initials only; do not design the image path in advance.
+- A gradient behind the text has to keep the text readable at the left edge in
+ both themes, which is the same failure mode `accentLineColour()` guards
+ against on a dark palette.
+- This is a looks question, so it is settled by the user looking at it rather
+ than by a test: assert the geometry in `CardLayout`, and hand the appearance
+ over per `tests-only-for-measurable-things`.
+
+## 172. A draft this application writes is tagged `unread`
+
+**Observed (user, 2026-08-27):** a draft they had edited was sitting in the
+Unread view. Reported first as "in the inbox view", corrected to Unread.
+
+**Cause (measured, 2026-08-27).** `ComposeWindow::saveDraftNow()` called
+`DraftStore::write(folder, bytes, "D", ...)`, and `DraftStore::write()` uses
+the flag string verbatim, so every draft this application wrote landed as
+`:2,D`. `maildir.synchronize_flags` is on, and notmuch tags any message
+lacking the `S` (seen) flag `unread`. A draft the user authored is seen by
+definition, so the tag was wrong the moment the file was written.
+
+**Why it looked intermittent, which is the part worth keeping.** The symptom
+heals itself: the next mbsync of that folder round-trips the file, adds `S`,
+and the tag goes away. On the developer's own mail two drafts written two
+minutes apart differed only in whether their folder had synced afterwards:
+one account's drafts folder had synced the next morning and its file read
+`,DS`, while the other's had last synced two minutes after the write and read
+`,D`. So only the newest draft in a folder that has not synced since shows
+it, and an investigation that measures an older draft finds nothing wrong.
+
+**A measurement trap sat in front of this and cost the first answer.**
+`notmuch search --output=tags` reports the union over a THREAD. A reply-draft
+attached to an inbox message therefore reads `draft inbox unread` while no
+single message carries both, which is the same union recorded for
+`ThreadSummary::tags` under item 110. The first pass here read that union as
+a draft carrying `inbox` and concluded there was no defect at all. Measure
+drafts with `--output=messages`; item 164's evidence is a thread-level
+reading and should be re-measured before it is worked on.
+
+**Fixed** by passing `"DS"`. `TestComposeWindow::aSavedDraftIsFlaggedSeen()`
+asserts both flags on the written filename, verified failing first (`got D`).
+
+`TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()` had to be
+repaired in the same commit: it asserted `endsWith(":2,D")`, pinning the whole
+flag set when its own comment said the point was the draft flag "not left
+bare". It therefore failed against the corrected behaviour. An
+over-specified assertion of this shape blocks the fix rather than the bug.
+
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 69aac44..fb659ec 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
@@ -242,8 +242,10 @@ taking that too literally.
| 166 | Mail you send to your own other account loses `inbox` | defect | S | **done 2026-08-25**, unreleased. `sent_only()` keeps a message only when EVERY file is inside a sent folder, which is what the carve-out's docstring already claimed. No query can express it, measured; the root comes from `database.mail_root`, with a split-index fixture the ordinary layout cannot provide. Verified read-only against the live index: 780 of 807 still stripped, 27 spared, no arrival affected |
| 167 | No way to tell one build of an unreleased version from another | enhancement | XS | **done 2026-08-25**, unreleased. The user chose a counter over a git description: `QTMAILDIR_BUILD_NUMBER`, a cmake option ON by default, increments a counter in the BUILD directory on every build and writes `buildnumber.h`. `QTMAILDIR_VERSION_DISPLAY` carries it; `QTMAILDIR_VERSION` stays clean and is what the window title, `applicationVersion` and the release procedure use |
| 168 | Delete is offered on mail already in the trash, and does nothing | defect | S | **done 2026-08-25**, unreleased. Delete is hidden when every selected row is already in its account's trash, Restore when none is, both keyed on the PATH rather than the `deleted` tag. Delete also drops `unread` now, in the same TagChange so one undo returns the folder and the tag together |
-| 169 | A card shows the account only as a bar, with no fade and no avatar | presentation | M | open, 2026-08-26, from the notes. The accent bar exists (`CardLayout::accentRect`, `CardDelegate::accentLineColour()`); the gradient fade and the sender avatar do not. The avatar's initials source is decided, the vCard half is blocked on item 72 |
+| 169 | A card shows the account only as a bar, with no fade and no avatar | presentation | M | **done** 2026-08-26, unreleased, on `card-avatars`, merged fast-forward. Both halves: a `QLinearGradient` from the account colour to the pane's base across the card, and a squircle avatar with initials, given a rect in `CardLayout` so the geometry is asserted without a painter. **Hand-testing found four defects**, all fixed in 9ae43f9: `Avatar::initialsFor()` normalises the display name first (drops the angle-addr, takes the first comma-separated author, unwraps quotes, treats a bare address as no name, requires a word to carry a letter or digit); the two-tone gradient axis spans the DIAMETER rather than a radius, which was letting one hue fill the whole face; the account fade runs right to left, anchored opaque at the card's right edge; and a flat view hashes `ThreadSummary::firstMessageRecipient` rather than the user's own address. The vCard half stays blocked on item 72 |
| 170 | A row that stops matching the view only leaves it on the Delete path | defect | S | open, 2026-08-26, from the notes, **cause found the same day and the premise is NOT stale**. The optimistic REPAINT is universal; the optimistic MEMBERSHIP is not. `removeThreadsWithoutTag()` has exactly one caller, on the move path, so marking a message read in the Unread view repaints the row and leaves it in a list it no longer belongs to |
+| 171 | A forwarded HTML message reaches the recipient as plain text | defect | S | open, 2026-08-27, from the notes. `ComposeContextBuilder::quoteBody()` reads `ParsedMessage::plainBody` only, so the original's `htmlBody` is dropped whatever the composer's own Send-as-HTML state is. Needs a DECISION on what a forward carries before any code, see the entry |
+| 172 | A draft this application writes is tagged `unread` | defect | XS | **done** 2026-08-27, unreleased. `DraftStore::write()` was called with `"D"`, and `maildir.synchronize_flags` makes notmuch tag anything without `S` as `unread`. Self-healing on the next sync of that folder, which is what made it look intermittent |
Sizes are rough: XS under an hour, S a sitting, M a session.
@@ -1261,49 +1263,6 @@ id and a draft of a reply carries both.
replaced correctly now, so the fork this would have mitigated no longer
happens by that route.
-## 169. A card shows the account only as a bar, with no fade and no avatar
-
-**Observed (user, from the notes):** "the left border of a card expresses the
-account the mail belongs to. the background color of the card should fade left
-to right from the account color to the current background color we are using (or
-to transparent to work both in light and dark themes). On the left we should
-leave room for an account avatar (a squircle), for now it could be extracted
-from the sender name "From: john doe" becomes "JD" in the avatar. As soon as we
-include khard (or some other vcard provider/manager) we will switch to images if
-the corresponding vCard has one."
-
-**Cause (verified in the code):** not a defect. Half of it shipped. The account
-colour is drawn as a solid bar down the left edge, `CardLayout::accentRect`
-placed by `CardLayout`, filled by `CardDelegate::paint()` with
-`CardDelegate::accentLineColour()`. There is no gradient anywhere on a card, and
-nothing draws an avatar: `CardLayout` reserves no rect for one, so the geometry
-would have to grow before the painting could.
-
-**Approach.** Two separable pieces, and the avatar is the one that changes the
-layout.
-
-- The fade is a `QLinearGradient` fill over the card rect, from the accent
- colour to the pane's background. `accentLineColour()` already records why
- blending toward the background is wrong for a CHIP; a card's background is
- exactly where such a blend belongs, so the constraint does not carry over.
- Both themes come free if the far stop is the palette's own base rather than
- a literal.
-- The avatar needs a rect in `CardLayout`, which is where it becomes testable
- without a painter, and it shifts `contentLeft` for every card. The initials
- come from the display name already carried on the summary; a sender with no
- display name (an address only) needs an answer before this is built.
-
-**Constraints.**
-
-- The vCard half is blocked on item 72, which is itself unspecified. Build the
- initials only; do not design the image path in advance.
-- A gradient behind the text has to keep the text readable at the left edge in
- both themes, which is the same failure mode `accentLineColour()` guards
- against on a dark palette.
-- This is a looks question, so it is settled by the user looking at it rather
- than by a test: assert the geometry in `CardLayout`, and hand the appearance
- over per `tests-only-for-measurable-things`.
-
## 170. A row that stops matching the view only leaves it on the Delete path
**Observed (user, from the notes):** "should we refactor the list UI to be
@@ -1353,3 +1312,62 @@ every tag write passes. Move it, or call it from both send paths.
thread the query never returned.
- Undo goes back through the same funnel, so a removal must not make an undone
mark-read invisible in the view it was undone in.
+
+## 171. A forwarded HTML message reaches the recipient as plain text
+
+**Observed (user, from the notes):** "forwarding an html message doesn't
+maintain the html formatting of the original message. #bug"
+
+**Cause (verified in the code, 2026-08-27).** The forward path builds its body
+through `ComposeContextBuilder::quoteBody()` (`src/composecontext.cpp`), which
+reads `message.plainBody` and nothing else. `MimeParser` parses both halves and
+`ParsedMessage` carries `htmlBody` beside `plainBody` (`src/mimeparser.h:150`),
+so the HTML is available and simply never asked for.
+
+Two consequences follow, and they are not the same severity:
+
+- An original with both parts forwards its text/plain alternative, losing the
+ sender's formatting. Recoverable-looking, since the words survive.
+- An original with an HTML part ONLY has an empty `plainBody`, so the forward
+ carries the attribution line and an empty quote. The message's content is
+ gone, and nothing says so.
+
+`MainWindow::composeReply()` already treats the two kinds differently for the
+composer's own HTML state: `context.seedHtml` is the CONFIG's `sendHtml` for a
+forward and `original.hasHtml()` for a reply, on the stated reasoning that an
+HTML part is a fact about the sender's software. That reasoning is sound for
+how the user WRITES and does not decide what the forward CARRIES, which is the
+question here.
+
+**Approach.** The decision comes first; this is not a changed call site.
+
+A forward is a different act from a reply: the point is to hand somebody else
+what arrived, and quoting is the wrong shape for it. Three candidates, in
+increasing fidelity:
+
+- Render `htmlBody` down to text when `plainBody` is empty, so nothing is
+ silently lost. The smallest fix, and it does not answer the note: formatting
+ is still gone.
+- Carry the original as a `message/rfc822` part, which is what item 130 already
+ describes and what GMime builds natively. Perfect fidelity, and every
+ attachment comes with it, but the recipient sees an attached message rather
+ than a body.
+- Build the forward as `multipart/alternative` with the original's HTML nested
+ in the HTML half, which is what Thunderbird's inline forward does.
+
+**Constraints.**
+
+- **The HTML is input from a stranger and the composer is not the message
+ pane.** The pane's protections (off-the-record profile, JavaScript off, the
+ interceptor blocking every request) are `MessageView`'s, not
+ `ComposeWindow`'s. Any route that puts the original's markup into an outgoing
+ message must decide what it strips, and remote references in particular:
+ forwarding a tracking pixel forwards the tracking to the new recipient.
+- Item 130 overlaps and may subsume this. Decide the two together rather than
+ building `message/rfc822` twice.
+- The markdown body is the composer's source of truth, and markdown has no
+ syntax for arbitrary HTML the user can then edit. A route that keeps the
+ original's markup has to keep it OUTSIDE the editable buffer, which is the
+ same nesting problem item 129 carries.
+- `quoteBody()` is shared with Reply. A change there reaches both; the
+ behaviour asked for is the forward's alone.
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index afcf6a2..9bcfab3 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -1227,8 +1227,15 @@ bool ComposeWindow::saveDraftNow()
account.maildir + QLatin1Char('/') + account.drafts);
const QString previousPath = m_draftPath;
+
+ // "DS", not "D". `maildir.synchronize_flags` is on, so notmuch tags any
+ // message lacking the `S` flag `unread`, and a draft the user just wrote
+ // is seen by definition. Without the S it lands in the Unread view until
+ // the folder next syncs, at which point mbsync round-trips the file and
+ // the flag appears on its own; that self-healing is what made the defect
+ // look intermittent rather than constant.
const DraftStore::Result written =
- DraftStore::write(folder, built.bytes, QStringLiteral("D"), m_draftPath);
+ DraftStore::write(folder, built.bytes, QStringLiteral("DS"), m_draftPath);
if (!written.ok()) {
// A PERSISTENT banner, not a modal and not a status-bar line that
diff --git a/tests/test_composewindow.cpp b/tests/test_composewindow.cpp
index 779d48c..0da61ff 100644
--- a/tests/test_composewindow.cpp
+++ b/tests/test_composewindow.cpp
@@ -20,6 +20,7 @@
#include <QComboBox>
#include <QDir>
#include <QFile>
+#include <QFileInfo>
#include <QMenu>
#include <QPlainTextEdit>
#include <QSignalSpy>
@@ -62,6 +63,7 @@ private slots:
void onlyTheSetterWritesTheDirtyFlag();
void theMenuBarReachesEveryComposerAction();
void saveDraftWritesAndReports();
+ void aSavedDraftIsFlaggedSeen();
void theMenusReuseTheToolbarActions();
void theHtmlMenuItemTracksTheToolbarButton();
void theAgeLineFollowsTheClock();
@@ -746,6 +748,58 @@ void TestComposeWindow::saveDraftWritesAndReports()
QVERIFY2(!window.isWindowModified(), "a manual save must clear the marker");
}
+/// A draft is authored by the user, so it is SEEN by definition and must never
+/// be tagged `unread`.
+///
+/// `maildir.synchronize_flags` is on, so the tag is decided by the filename:
+/// notmuch tags any message lacking the `S` flag `unread`. Writing a draft as
+/// `:2,D` therefore puts it in the Unread view until the folder next syncs,
+/// at which point mbsync round-trips the file and the `S` appears. That is
+/// what made the defect look intermittent: only the newest draft, in a folder
+/// that has not synced since, shows the symptom. Measured on the user's own
+/// mail 2026-08-27, where two drafts written two minutes apart differed only
+/// in whether their folder had synced afterwards.
+///
+/// Asserting on the FILENAME rather than on a notmuch tag is deliberate: the
+/// flags are what the code here controls, and a tag assertion would need an
+/// indexed database to say the same thing less directly.
+void TestComposeWindow::aSavedDraftIsFlaggedSeen()
+{
+ const Config config = configWithDrafts();
+
+ ComposeContext context;
+ context.kind = ComposeContext::Kind::New;
+ context.accountKey = QStringLiteral("work");
+
+ ComposeWindow window(context, config, m_dir->path());
+ auto *body = window.findChild<QPlainTextEdit *>(QStringLiteral("body"));
+ QVERIFY(body);
+ body->setPlainText(QStringLiteral("A draft the user wrote."));
+
+ QSignalSpy saved(&window, &ComposeWindow::draftSaved);
+ auto *save = window.findChild<QAction *>(QStringLiteral("compose_save"));
+ QVERIFY(save);
+ save->trigger();
+
+ QCOMPARE(saved.size(), 1);
+ const QString path = saved.first().first().toString();
+ QVERIFY2(!path.isEmpty(), "the save reported no path");
+
+ // The guard the probe needs: without it a rename that dropped the info
+ // suffix entirely would pass the S check below by never reaching it.
+ const QString name = QFileInfo(path).fileName();
+ QVERIFY2(name.contains(QStringLiteral(":2,")),
+ qPrintable(QStringLiteral("no Maildir info suffix in %1").arg(name)));
+
+ const QString flags = name.section(QStringLiteral(":2,"), 1);
+ QVERIFY2(flags.contains(QLatin1Char('D')),
+ qPrintable(QStringLiteral("a draft must carry the D flag, got %1")
+ .arg(flags)));
+ QVERIFY2(flags.contains(QLatin1Char('S')),
+ qPrintable(QStringLiteral("a draft must carry the S flag or notmuch "
+ "tags it unread, got %1").arg(flags)));
+}
+
/// The same QAction objects, shown twice over, exactly as item 140 required
/// for the message pane's bar. A copy would drift: an enablement change or a
/// new shortcut would reach one surface and not the other.
diff --git a/tests/test_mainwindow.cpp b/tests/test_mainwindow.cpp
index a572fd0..2fbdb20 100644
--- a/tests/test_mainwindow.cpp
+++ b/tests/test_mainwindow.cpp
@@ -13753,8 +13753,12 @@ void TestMainWindow::anAutosaveWritesADraftAndClearsTheDirtyFlag()
QVERIFY(written.open(QIODevice::ReadOnly));
const QByteArray bytes = written.readAll();
QVERIFY2(bytes.contains("Draft body."), "the draft does not carry the body");
- // Written with the Maildir draft flag, not left bare.
- QVERIFY2(files.first().endsWith(QStringLiteral(":2,D")),
+ // Written with the Maildir draft flag, not left bare. The flag SET is not
+ // pinned here: a draft also carries S, asserted by
+ // TestComposeWindow::aSavedDraftIsFlaggedSeen(), and an endsWith(":2,D")
+ // here would fail against that correct behaviour.
+ QVERIFY2(files.first().section(QStringLiteral(":2,"), 1)
+ .contains(QLatin1Char('D')),
qPrintable(QStringLiteral("wrong maildir flags: ") + files.first()));
}