aboutsummaryrefslogtreecommitdiffstats
path: root/src/messagebuilder.cpp
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-08-27 13:05:17 +0200
committerDanilo M. <danix@danix.xyz>2026-08-27 13:05:17 +0200
commit5570d0e7495a42a90acf951d396c9185b0319eb9 (patch)
tree4052c9060b6ade33577c9bcb285565a1477bd3eb /src/messagebuilder.cpp
parent12e841b8e2c4c225ea79de87dc7bb50f0404ee69 (diff)
downloadqtmaildir-5570d0e7495a42a90acf951d396c9185b0319eb9.tar.gz
qtmaildir-5570d0e7495a42a90acf951d396c9185b0319eb9.zip
feat: forward an HTML message with its formattingHEADmaster
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AtUzfNjMD8fiYfamDd3ywW
Diffstat (limited to 'src/messagebuilder.cpp')
-rw-r--r--src/messagebuilder.cpp35
1 files changed, 34 insertions, 1 deletions
diff --git a/src/messagebuilder.cpp b/src/messagebuilder.cpp
index 42a0e31..fe23862 100644
--- a/src/messagebuilder.cpp
+++ b/src/messagebuilder.cpp
@@ -320,7 +320,40 @@ Result build(const OutgoingMessage &message, const Account &account)
// second renderer whose output could disagree with the HTML one.
GMimeObject *body = GMIME_OBJECT(makeTextPart("plain", message.markdownBody));
- if (message.sendHtml) {
+ // Item 171. A FORWARD sends one part, not an alternative, at the user's
+ // decision 2026-08-27: the Send-as-HTML toggle chooses which. A forward is
+ // a message whose shape the user has already decided by flipping that
+ // toggle, and sending both halves hands the choice to the recipient's
+ // client instead.
+ //
+ // The toggle is honoured even when the original had no plain-text part:
+ // with it off, an HTML-only original goes out as the text fallback that
+ // quoteBody() produced, and the formatting is lost. Chosen over forcing
+ // HTML for those messages, so the toggle means what it says.
+ //
+ // The markup arrives ALREADY SANITISED: whether to strip remote content is
+ // the user's per-forward choice, which a builder cannot see. Nothing is
+ // escaped here, deliberately, because this IS markup and escaping it would
+ // ship a message full of visible tags.
+ const bool forwarding = !message.forwardedHtml.isEmpty();
+
+ if (forwarding && message.sendHtml) {
+ // The original below the user's own text, separated by a rule so the
+ // two read as different messages. The plain part built above is
+ // discarded: this replaces it rather than joining it.
+ // `markdownBody` is the user's own note ALONE: the composer does not
+ // seed a text quote on an HTML forward, precisely so that what it
+ // shows and what it sends are the same thing (item 171). An earlier
+ // build seeded the quote and stripped it again here, which meant the
+ // user could edit a quote whose edits were discarded; the fix belongs
+ // at the composer, not in a subtraction here.
+ const QString htmlSource = MarkdownRenderer::toHtml(message.markdownBody)
+ + QStringLiteral("\n<hr>\n")
+ + message.forwardedHtml;
+ GMimePart *html = makeTextPart("html", htmlSource);
+ g_object_unref(body);
+ body = GMIME_OBJECT(html);
+ } else if (!forwarding && message.sendHtml) {
GMimePart *html = makeTextPart("html", MarkdownRenderer::toHtml(message.markdownBody));
GMimeMultipart *alternative = g_mime_multipart_new_with_subtype("alternative");
// Least-rich FIRST. A client renders the LAST alternative it