From 5570d0e7495a42a90acf951d396c9185b0319eb9 Mon Sep 17 00:00:00 2001 From: "Danilo M." Date: Thu, 27 Aug 2026 13:05:17 +0200 Subject: feat: forward an HTML message with its formatting Item 171. A forward carried only the plain-text version of the original, so formatting was lost; and an original with no plain-text part at all (30 of 342 sampled inbox messages, ~9%) forwarded as an empty quote with its content silently gone. A forward now sends ONE part chosen by the Send-as-HTML toggle: the original's markup when on, the text quote when off. Not a multipart/alternative, at the user's decision: a forward's shape is already decided by that toggle, and sending both hands the choice to the recipient's client. The toggle is honoured even for an HTML-only original, which then forwards as a text fallback. HtmlSanitiser strips remote content from the forwarded markup, checked by default with a per-forward opt-out. This is the security-critical part: the markup leaves this process and is rendered by the recipient's client, where none of MessageView's protections apply, so forwarding a tracking pixel forwards the tracking. It is an ALLOW-LIST, unlike HtmlBuilder::namespaceCids(), because a missed rewrite is a broken image while a missed strip is a beacon reaching the recipient. An HTML forward does not seed a text quote into the editor. The first build did, then subtracted it when building the HTML part, so the user could edit a quote whose edits were discarded; what the composer shows must be what gets sent. The forwarded message appears in a read-only pane beside the editor instead, a QSplitter at 60/40 with a toggle in the Format menu. A plain forward is unchanged. ComposeContextBuilder::quoteBody() renders htmlBody down to text when there is no plain part, so the plain path never emits an empty quote. Design in docs/superpowers/specs/2026-08-27-forward-html-design.md. Two tests repaired for the splitter: the 60/40 assertion reads stretch factors rather than pixels, since the offscreen platform gives the splitter no width and reports 49/49 whatever the code asks; and theComposerSplitsItsToolbarByScope looked for the body directly in the composer's column. Not yet hand-tested in this arrangement. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW --- tests/test_composecontext.cpp | 53 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) (limited to 'tests/test_composecontext.cpp') diff --git a/tests/test_composecontext.cpp b/tests/test_composecontext.cpp index bea390d..50b7ab3 100644 --- a/tests/test_composecontext.cpp +++ b/tests/test_composecontext.cpp @@ -96,6 +96,7 @@ private slots: // Quoting. void aQuotedBodyPrefixesEveryLine(); + void anHtmlOnlyBodyIsQuotedAsText(); private: QString writeConfig(const QString &contents); @@ -1124,5 +1125,57 @@ void TestComposeContext::aQuotedBodyPrefixesEveryLine() .arg(quotedCrlf))); } +/// Item 171's silent half. An HTML-only original has an EMPTY `plainBody`, so +/// quoting it produced an attribution line and nothing else: the content was +/// gone and nothing said so. Measured on the developer's own inbox 2026-08-27, +/// 30 of 342 sampled messages (~9%) declare text/html with no text/plain, so +/// this is not an edge case. +/// +/// The fallback renders the HTML down to text. It does NOT preserve +/// formatting, which is the separate half of item 171 and is answered by the +/// multipart/alternative build; this only guarantees the words survive. +void TestComposeContext::anHtmlOnlyBodyIsQuotedAsText() +{ + ParsedMessage message; + message.from = QStringLiteral("Sender "); + message.date = QStringLiteral("Thu, 20 Aug 2026 10:00:00 +0200"); + message.htmlBody = QStringLiteral( + "

Revenue rose 12% against forecast.

  • Region A
"); + // plainBody deliberately empty: this is the shape that lost the content. + + const QString quoted = ComposeContextBuilder::quoteBody(message); + + QVERIFY2(quoted.contains(QStringLiteral("Revenue rose")), + qPrintable(QStringLiteral("the body was lost:\n%1").arg(quoted))); + QVERIFY2(quoted.contains(QStringLiteral("Region A")), + qPrintable(QStringLiteral("list content was lost:\n%1").arg(quoted))); + QVERIFY2(quoted.contains(QStringLiteral("12%")), + qPrintable(QStringLiteral("emphasised text was lost:\n%1").arg(quoted))); + + // Quoted like any other body, not dumped raw. + QVERIFY2(quoted.contains(QStringLiteral("> Revenue rose")), + qPrintable(QStringLiteral("the fallback is not quoted:\n%1").arg(quoted))); + + // Text, not markup: the plain half of a message must not carry tags. + QVERIFY2(!quoted.contains(QStringLiteral("")), + qPrintable(QStringLiteral("markup reached the plain quote:\n%1").arg(quoted))); + QVERIFY2(!quoted.contains(QStringLiteral("

")), + qPrintable(QStringLiteral("markup reached the plain quote:\n%1").arg(quoted))); + + // A message WITH a plain part must keep using it, untouched: the fallback + // is for the empty case only, and rendering HTML over a real plain part + // would change every ordinary reply. + ParsedMessage both; + both.plainBody = QStringLiteral("the real plain part"); + both.htmlBody = QStringLiteral("

the html part

"); + const QString preferred = ComposeContextBuilder::quoteBody(both); + QVERIFY2(preferred.contains(QStringLiteral("> the real plain part")), + qPrintable(QStringLiteral("the plain part was not preferred:\n%1") + .arg(preferred))); + QVERIFY2(!preferred.contains(QStringLiteral("the html part")), + qPrintable(QStringLiteral("the html part was used anyway:\n%1") + .arg(preferred))); +} + QTEST_MAIN(TestComposeContext) #include "test_composecontext.moc" -- cgit v1.2.3