aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt1
-rw-r--r--src/composecontext.cpp21
-rw-r--r--src/composewindow.cpp178
-rw-r--r--src/composewindow.h31
-rw-r--r--src/htmlsanitiser.cpp336
-rw-r--r--src/htmlsanitiser.h84
-rw-r--r--src/messagebuilder.cpp35
-rw-r--r--src/types.h15
8 files changed, 698 insertions, 3 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 591e95f..85b5e56 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -6,6 +6,7 @@ add_library(qtmaildir_lib STATIC
messagebuilder.cpp
requestinterceptor.cpp
htmlbuilder.cpp
+ htmlsanitiser.cpp
cidschemehandler.cpp
cardlayout.cpp
avatar.cpp
diff --git a/src/composecontext.cpp b/src/composecontext.cpp
index 2dfee53..b02b790 100644
--- a/src/composecontext.cpp
+++ b/src/composecontext.cpp
@@ -30,6 +30,7 @@
#include <QDir>
#include <QSet>
#include <QRegularExpression>
+#include <QTextDocumentFragment>
namespace {
@@ -630,10 +631,28 @@ QString ComposeContextBuilder::quoteBody(const ParsedMessage &message)
.arg(message.date, message.from));
quoted.append(QString());
+ // 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 with nothing to say so. Measured 2026-08-27, ~9% of the
+ // developer's inbox declares text/html with no text/plain.
+ //
+ // Only when there is no plain part. Rendering the HTML over a real plain
+ // part would change every ordinary reply, and the sender's own plain text
+ // is what they meant a text reader to see.
+ //
+ // QTextDocumentFragment, not a hand-written stripper: it is already
+ // linked, it decodes entities and collapses whitespace the way a reader
+ // expects, and reaching for a regex here would be re-implementing a parser
+ // badly. This restores the WORDS only; preserving the formatting is the
+ // multipart/alternative half of item 171.
+ QString source = message.plainBody;
+ if (source.isEmpty() && !message.htmlBody.isEmpty())
+ source = QTextDocumentFragment::fromHtml(message.htmlBody).toPlainText();
+
// Normalised to LF first. A CRLF body split on '\n' alone leaves a
// carriage return at the end of every line, which survives into the sent
// message as a stray CR in the middle of a quoted line.
- QString body = message.plainBody;
+ QString body = source;
body.replace(QStringLiteral("\r\n"), QStringLiteral("\n"));
body.replace(QLatin1Char('\r'), QLatin1Char('\n'));
diff --git a/src/composewindow.cpp b/src/composewindow.cpp
index 9bcfab3..7952efa 100644
--- a/src/composewindow.cpp
+++ b/src/composewindow.cpp
@@ -21,6 +21,10 @@
#include <QTemporaryDir>
#include "draftstore.h"
+#include "htmlsanitiser.h"
+#include <QCheckBox>
+#include <QTextBrowser>
+#include <QSplitter>
#include "maildirname.h"
#include "messagebuilder.h"
#include "mimeparser.h"
@@ -134,6 +138,16 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
buildUi();
buildFormatToolbar();
+
+ // BEFORE buildMenuBar() and seedBody(). The menus show the pane toggle, so
+ // the preview that owns it must exist first; and seedBody() needs to know
+ // whether this forward carries markup, because an HTML forward does not
+ // seed a text quote at all (the buffer would then show something the sent
+ // message does not contain).
+ readForwardedHtml();
+ buildStripRemoteControl();
+ buildForwardPreview();
+
// AFTER buildFormatToolbar(): the menus show its actions, so they must
// exist before a menu can hold them.
buildMenuBar();
@@ -147,6 +161,7 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
// on it, which is precisely the defect this fixes.
extractForwardedAttachments();
+
refreshAttachmentList();
// Seeding is not an edit. Every field was just filled from the context, so
@@ -173,6 +188,127 @@ ComposeWindow::ComposeWindow(const ComposeContext &context,
ComposeWindow::~ComposeWindow() = default;
+void ComposeWindow::readForwardedHtml()
+{
+ if (m_context.kind != ComposeContext::Kind::Forward
+ || m_context.originalPath.isEmpty()) {
+ return;
+ }
+
+ MimeParser parser;
+ const ParsedMessage original = parser.parse(m_context.originalPath);
+ if (original.ok)
+ m_forwardedHtmlRaw = original.htmlBody;
+}
+
+void ComposeWindow::buildForwardPreview()
+{
+ if (m_forwardedHtmlRaw.isEmpty())
+ return;
+
+ m_forwardPreview = new QWidget(centralWidget());
+ m_forwardPreview->setObjectName(QStringLiteral("forwardPreview"));
+ auto *previewLayout = new QVBoxLayout(m_forwardPreview);
+ previewLayout->setContentsMargins(0, 0, 0, 0);
+
+ auto *label = new QLabel(
+ tr("Forwarded message, sent as it arrived:"), m_forwardPreview);
+ label->setObjectName(QStringLiteral("forwardPreviewLabel"));
+ previewLayout->addWidget(label);
+
+ // **QTextBrowser, not a QWebEngineView.** A web view would mean a second
+ // Chromium render process per composer window and a second copy of
+ // MessageView's protections (the off-the-record profile, JavaScript off,
+ // the interceptor that fails closed), which is a lot of security surface
+ // for a preview. QTextBrowser renders Qt's own HTML subset, is read-only,
+ // and fetches nothing on its own.
+ //
+ // The consequence is deliberate and must be said in the UI rather than
+ // hidden: this shows the original ROUGHLY. Qt's subset is narrower than a
+ // mail client's, so the preview is an indication of content, not a
+ // faithful rendering of what the recipient will see. The label above says
+ // the message is sent as it arrived, so the user is not led to think this
+ // pane is what travels.
+ auto *view = new QTextBrowser(m_forwardPreview);
+ view->setObjectName(QStringLiteral("forwardPreviewBody"));
+ view->setOpenExternalLinks(false);
+ view->setOpenLinks(false);
+
+ // The SANITISED markup when stripping is on, so the preview shows what
+ // will actually be sent rather than the original's own remote content.
+ // Re-rendered when the checkbox moves, for the same reason.
+ view->setHtml(HtmlSanitiser::stripRemoteContent(m_forwardedHtmlRaw));
+ previewLayout->addWidget(view);
+
+ m_split->addWidget(m_forwardPreview);
+
+ // 60/40, the user's ratio. Set as STRETCH FACTORS rather than as pixel
+ // sizes: the window has no meaningful width yet at construction, and a
+ // setSizes() against a zero-width splitter divides nothing. Stretch
+ // survives the first real resize, which pixels would not.
+ m_split->setStretchFactor(0, 6);
+ m_split->setStretchFactor(1, 4);
+ m_split->setSizes({ 600, 400 });
+
+ // The toggle, on the View half of the menus so it sits with the other
+ // things that show and hide. Only created for a forward that has a pane to
+ // toggle: an action that can never do anything is worse than none, which
+ // is the same reasoning the attachment row's Remove button follows.
+ m_showForwardAction = new QAction(tr("Forwarded message"), this);
+ m_showForwardAction->setObjectName(QStringLiteral("compose_show_forward"));
+ m_showForwardAction->setCheckable(true);
+ m_showForwardAction->setChecked(true);
+ m_showForwardAction->setToolTip(
+ tr("Shows the message being forwarded beside what you are writing."));
+ connect(m_showForwardAction, &QAction::toggled, m_forwardPreview,
+ &QWidget::setVisible);
+ // Placed by buildMenuBar(), which runs after this.
+
+ if (m_stripRemote) {
+ connect(m_stripRemote, &QCheckBox::toggled, view,
+ [this, view](bool strip) {
+ view->setHtml(strip ? HtmlSanitiser::stripRemoteContent(
+ m_forwardedHtmlRaw)
+ : m_forwardedHtmlRaw);
+ });
+ }
+}
+
+void ComposeWindow::buildStripRemoteControl()
+{
+ if (m_forwardedHtmlRaw.isEmpty()
+ || !HtmlSanitiser::hasRemoteContent(m_forwardedHtmlRaw)) {
+ return;
+ }
+
+ m_stripRemote = new QCheckBox(
+ tr("Strip remote content from the forwarded message"), this);
+ m_stripRemote->setObjectName(QStringLiteral("stripRemote"));
+
+ // **Checked by default, and that default is the security property.** The
+ // markup leaves this process and is rendered by the recipient's client,
+ // where none of MessageView's protections apply: forwarding a tracking
+ // pixel forwards the tracking, and the original sender learns that the
+ // forwarded copy was opened and by how many people.
+ m_stripRemote->setChecked(true);
+ m_stripRemote->setToolTip(
+ tr("Images and styles loaded from the internet are removed, so the "
+ "sender of the original cannot tell that you forwarded it. Uncheck "
+ "only for a sender you trust."));
+
+ connect(m_stripRemote, &QCheckBox::toggled, this, &ComposeWindow::markDirty);
+
+ // Above the attachment row, which is where the other per-message controls
+ // sit. Inserted rather than appended: the body must keep its stretch.
+ if (auto *layout = qobject_cast<QVBoxLayout *>(centralWidget()->layout())) {
+ const int at = layout->indexOf(m_attachmentRow);
+ if (at >= 0)
+ layout->insertWidget(at, m_stripRemote);
+ else
+ layout->addWidget(m_stripRemote);
+ }
+}
+
void ComposeWindow::extractForwardedAttachments()
{
if (m_context.kind != ComposeContext::Kind::Forward
@@ -353,7 +489,17 @@ void ComposeWindow::buildUi()
m_body = new QPlainTextEdit(central);
m_body->setObjectName(QStringLiteral("body"));
- layout->addWidget(m_body, 1);
+
+ // The editor lives in a splitter so an HTML forward can put the forwarded
+ // message BESIDE it rather than under it (item 171, the user's choice
+ // 2026-08-27). With nothing to show the splitter holds one widget and is
+ // indistinguishable from the plain editor it replaces, so every other
+ // composer is unaffected.
+ m_split = new QSplitter(Qt::Horizontal, central);
+ m_split->setObjectName(QStringLiteral("composeSplit"));
+ m_split->setChildrenCollapsible(false);
+ m_split->addWidget(m_body);
+ layout->addWidget(m_split, 1);
// The attachment list, with Remove beside it: the control acts on the
// list, so it lives with it, and both appear only once something is
@@ -719,6 +865,13 @@ void ComposeWindow::buildMenuBar()
// copy of its entries would go stale on the next rebuild.
QAction *signature = format->addMenu(m_signatureSwitch->menu());
signature->setText(tr("Signature"));
+
+ // Item 171. Only on a forward that has a pane to toggle; an action that
+ // can never do anything is worse than no action at all.
+ if (m_showForwardAction) {
+ format->addSeparator();
+ format->addAction(m_showForwardAction);
+ }
}
void ComposeWindow::seedFields()
@@ -791,6 +944,17 @@ void ComposeWindow::seedBody()
if (m_context.quotedBody.isEmpty())
return;
+ // Item 171. An HTML forward carries the original as MARKUP, so seeding the
+ // text quote here would put something in the buffer that the sent message
+ // does not contain: the user could edit it and the edits would be
+ // discarded silently. The preview below the editor shows what is actually
+ // carried. A PLAIN forward is untouched, because there the quote in the
+ // buffer IS what gets sent.
+ if (m_context.kind == ComposeContext::Kind::Forward
+ && !m_forwardedHtmlRaw.isEmpty()) {
+ return;
+ }
+
// Applied when the window opens and never again. The buffer is text the
// user owns after that, and there is deliberately no live toggle:
// tracking "my text" and "the quote" as separate pieces to make a toggle
@@ -1016,6 +1180,18 @@ OutgoingMessage ComposeWindow::currentMessage() const
message.subject = m_subject->text();
message.markdownBody = m_body->toPlainText();
message.sendHtml = m_sendHtml->isChecked();
+
+ // Item 171. Sanitised HERE rather than at parse time, so the control can
+ // be toggled without re-reading the file, and so the raw markup is never
+ // what reaches OutgoingMessage by default. The checkbox only exists when
+ // there is remote content to strip; without it the raw markup IS the safe
+ // markup, which is why the fallback is `true` rather than `false`.
+ if (!m_forwardedHtmlRaw.isEmpty()) {
+ const bool strip = m_stripRemote ? m_stripRemote->isChecked() : true;
+ message.forwardedHtml =
+ strip ? HtmlSanitiser::stripRemoteContent(m_forwardedHtmlRaw)
+ : m_forwardedHtmlRaw;
+ }
message.attachments = m_attachments;
message.inReplyTo = m_context.inReplyTo;
message.references = m_context.references;
diff --git a/src/composewindow.h b/src/composewindow.h
index c8cc12a..9affce1 100644
--- a/src/composewindow.h
+++ b/src/composewindow.h
@@ -34,6 +34,7 @@
class QAction;
class QCheckBox;
+class QSplitter;
class QComboBox;
class QLabel;
class QLineEdit;
@@ -223,6 +224,15 @@ private:
/// MessageBuilder refuses a build naming any path that later vanishes, so
/// a silently wrong send is not among the outcomes.
void extractForwardedAttachments();
+
+ /// Reads the forwarded original's HTML, before the body is seeded.
+ void readForwardedHtml();
+
+ /// Creates the strip-remote-content checkbox, for a forward that needs it.
+ void buildStripRemoteControl();
+
+ /// Creates the read-only preview of what an HTML forward will carry.
+ void buildForwardPreview();
void seedBody();
/// Applies \p name to the buffer, replacing whatever is there.
@@ -300,6 +310,27 @@ private:
QComboBox *m_from = nullptr;
QPlainTextEdit *m_body = nullptr;
QToolButton *m_sendHtml = nullptr;
+
+ /// Item 171. Strips remote content from the forwarded original, checked by
+ /// default. Only created for a Forward whose original carries remote
+ /// content, so an ordinary message gains no control.
+ QCheckBox *m_stripRemote = nullptr;
+
+ /// Read-only view of the original an HTML forward will carry. Item 171:
+ /// the buffer holds the user's note only, so this is what makes the rest
+ /// of the message visible without pretending it can be edited.
+ QWidget *m_forwardPreview = nullptr;
+
+ /// Holds the editor, and the forward preview beside it when there is one.
+ QSplitter *m_split = nullptr;
+
+ /// Shows and hides the forwarded-message pane. Only for a forward.
+ QAction *m_showForwardAction = nullptr;
+
+ /// The forwarded original's HTML, as read from `originalPath` at
+ /// construction. Held raw; the stripping happens at currentMessage(),
+ /// so toggling the control does not need a re-parse.
+ QString m_forwardedHtmlRaw;
QToolButton *m_signatureSwitch = nullptr;
QString m_signatureDir;
QString m_signatureName; ///< The selected signature, empty for None.
diff --git a/src/htmlsanitiser.cpp b/src/htmlsanitiser.cpp
new file mode 100644
index 0000000..656f4fd
--- /dev/null
+++ b/src/htmlsanitiser.cpp
@@ -0,0 +1,336 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#include "htmlsanitiser.h"
+
+#include <QRegularExpression>
+#include <QSet>
+#include <QStringList>
+
+namespace {
+
+/// Elements that exist to fetch or redirect, removed with their content.
+///
+/// `script` and `style` carry their payload as TEXT, so emptying an attribute
+/// would leave the fetch intact; the element goes whole. `style` is here even
+/// though CSS is otherwise kept, because a block can carry `@import` and
+/// `url()` and rewriting inside it is the same problem as the attribute case
+/// with different terminators. Presentational styling survives through
+/// `style=""` attributes, which are handled per attribute below.
+const QSet<QString> &elementsRemovedWhole()
+{
+ static const QSet<QString> set = {
+ QStringLiteral("script"), QStringLiteral("style"),
+ QStringLiteral("iframe"), QStringLiteral("object"),
+ QStringLiteral("embed"), QStringLiteral("applet"),
+ QStringLiteral("frame"), QStringLiteral("frameset"),
+ };
+ return set;
+}
+
+/// Void elements that fetch or redirect and have no closing tag.
+const QSet<QString> &voidElementsRemoved()
+{
+ static const QSet<QString> set = {
+ QStringLiteral("link"), QStringLiteral("base"),
+ QStringLiteral("meta"),
+ };
+ return set;
+}
+
+/// True when \p value names something that would be fetched from off-message.
+///
+/// The test is on the SCHEME, never on a substring: a `cid:` id may legitimately
+/// contain "https" (`cid:https-logo@example.org`), and stripping that would
+/// destroy a valid inline reference.
+///
+/// Everything that is not plainly a `cid:` or a same-document fragment counts
+/// as remote. That is the allow-list rule: an unanticipated scheme is remote by
+/// default rather than by enumeration.
+bool valueIsRemote(const QString &value)
+{
+ const QString trimmed = value.trimmed();
+ if (trimmed.isEmpty())
+ return false;
+
+ // Protocol-relative: no scheme, still fetches, and a check for "http"
+ // misses it entirely.
+ if (trimmed.startsWith(QLatin1String("//")))
+ return true;
+
+ // A same-document fragment or a bare relative path fetches nothing new in
+ // a mail context and carries no scheme to judge.
+ const qsizetype colon = trimmed.indexOf(QLatin1Char(':'));
+ if (colon < 0)
+ return !trimmed.startsWith(QLatin1Char('#')) ? false : false;
+
+ const QString scheme = trimmed.left(colon).toLower();
+
+ // A colon can appear in a relative path with no scheme before it; a scheme
+ // is letters, digits, '+', '-', '.' only.
+ static const QRegularExpression schemeShape(
+ QStringLiteral("^[a-z][a-z0-9+.-]*$"));
+ if (!schemeShape.match(scheme).hasMatch())
+ return false;
+
+ return scheme != QLatin1String("cid");
+}
+
+/// True when a CSS fragment reaches the network.
+///
+/// `url()`, `@import` and `image-set()` all fetch, in a `style=""` attribute
+/// and in a block alike. The bare `url(...)` form terminates on ')' rather
+/// than on a quote, which is why this is a pass of its own rather than the
+/// attribute logic reused.
+bool cssIsRemote(const QString &css)
+{
+ static const QRegularExpression url(
+ QStringLiteral("url\\s*\\(\\s*['\"]?([^'\")]*)"),
+ QRegularExpression::CaseInsensitiveOption);
+
+ auto it = url.globalMatch(css);
+ while (it.hasNext()) {
+ if (valueIsRemote(it.next().captured(1)))
+ return true;
+ }
+
+ static const QRegularExpression atImport(
+ QStringLiteral("@import\\s+['\"]?([^'\";]*)"),
+ QRegularExpression::CaseInsensitiveOption);
+ auto imports = atImport.globalMatch(css);
+ while (imports.hasNext()) {
+ const QString target = imports.next().captured(1).trimmed();
+ // `@import url(...)` is already covered by the url() pass; a bare
+ // `@import "x.css"` is not.
+ if (!target.startsWith(QLatin1String("url"), Qt::CaseInsensitive)
+ && valueIsRemote(target)) {
+ return true;
+ }
+ }
+
+ // image-set() wraps url() in every real spelling, so the url() pass above
+ // covers it; a bare image-set("x.png") is caught here.
+ static const QRegularExpression imageSet(
+ QStringLiteral("image-set\\s*\\(\\s*['\"]([^'\"]*)"),
+ QRegularExpression::CaseInsensitiveOption);
+ auto sets = imageSet.globalMatch(css);
+ while (sets.hasNext()) {
+ if (valueIsRemote(sets.next().captured(1)))
+ return true;
+ }
+
+ return false;
+}
+
+/// One attribute, as parsed out of a tag.
+struct Attribute
+{
+ QString name; ///< Lowercased.
+ QString raw; ///< The whole `name="value"` source, to re-emit verbatim.
+ QString value; ///< Unquoted.
+};
+
+/// Splits the inside of a tag into its attributes.
+///
+/// Tolerates the three quoting forms and whitespace or newlines around '=',
+/// all of which are real in mail and each of which alone defeats a naive
+/// pattern.
+QList<Attribute> parseAttributes(const QString &inner)
+{
+ QList<Attribute> out;
+
+ static const QRegularExpression attr(
+ QStringLiteral("([a-zA-Z_:][-a-zA-Z0-9_:.]*)" // name
+ "(?:\\s*=\\s*" // = with space
+ "(?:\"([^\"]*)\"|'([^']*)'|([^\\s>]+))" // 3 quotings
+ ")?"));
+
+ auto it = attr.globalMatch(inner);
+ while (it.hasNext()) {
+ const QRegularExpressionMatch m = it.next();
+ Attribute a;
+ a.name = m.captured(1).toLower();
+ a.raw = m.captured(0);
+ for (int group : { 2, 3, 4 }) {
+ if (m.hasCaptured(group)) {
+ a.value = m.captured(group);
+ break;
+ }
+ }
+ out.append(a);
+ }
+
+ return out;
+}
+
+/// True when this attribute must not survive, whatever element carries it.
+bool attributeIsUnsafe(const Attribute &attr)
+{
+ // Event handlers. The recipient's client most likely disables scripting,
+ // but that is their policy and not ours to assume for them.
+ if (attr.name.startsWith(QLatin1String("on")))
+ return true;
+
+ // CSS reaches the network from inside a style attribute.
+ if (attr.name == QLatin1String("style"))
+ return cssIsRemote(attr.value);
+
+ // **The allow-list.** Every other attribute is judged by its VALUE rather
+ // than by whether the name was enumerated. This is the difference from
+ // HtmlBuilder::namespaceCids(), which names the attributes it rewrites and
+ // scopes srcset out: a missed rewrite is a broken image, a missed strip is
+ // a beacon. srcset, poster, data-*, and whatever HTML adds next are all
+ // handled here by default.
+ //
+ // srcset carries a LIST of "url descriptor" pairs, so each entry is
+ // judged; a single remote entry condemns the attribute.
+ for (const QString &piece : attr.value.split(QLatin1Char(','))) {
+ const QString candidate = piece.trimmed().section(QLatin1Char(' '), 0, 0);
+ if (valueIsRemote(candidate))
+ return true;
+ }
+
+ return false;
+}
+
+/// The shared walk. \p report is called for anything that would be removed;
+/// when \p rewrite is false the walk only reports, which is what
+/// hasRemoteContent() needs.
+QString walk(const QString &html, bool *foundOut)
+{
+ QString out;
+ out.reserve(html.size());
+ bool found = false;
+
+ static const QRegularExpression tag(QStringLiteral("<(/?)([a-zA-Z][^\\s/>]*)([^>]*)>"));
+
+ // Matched by hand from `pos` rather than with globalMatch(): skipping a
+ // removed element's CONTENT moves pos forward, and globalMatch iterates
+ // over matches found against the original string, so it would hand back
+ // tags from inside the region just skipped. That produced duplicated
+ // output and a surviving iframe, caught by
+ // aFetchingElementIsRemovedWhole().
+ qsizetype pos = 0;
+ while (true) {
+ const QRegularExpressionMatch m = tag.match(html, pos);
+ if (!m.hasMatch())
+ break;
+
+ out += html.mid(pos, m.capturedStart() - pos);
+ pos = m.capturedEnd();
+
+ const bool closing = !m.captured(1).isEmpty();
+ const QString name = m.captured(2).toLower();
+ const QString inner = m.captured(3);
+
+ if (elementsRemovedWhole().contains(name)) {
+ found = true;
+ if (!closing) {
+ // Drop the content too: for script and style it IS the payload.
+ const QRegularExpression until(
+ QStringLiteral("</\\s*%1\\s*>").arg(name),
+ QRegularExpression::CaseInsensitiveOption);
+ const QRegularExpressionMatch end = until.match(html, pos);
+ pos = end.hasMatch() ? end.capturedEnd() : html.size();
+ }
+ continue;
+ }
+
+ if (voidElementsRemoved().contains(name)) {
+ // meta is only dangerous as a refresh; the charset declaration is
+ // ordinary and harmless.
+ if (name == QLatin1String("meta")) {
+ bool refresh = false;
+ for (const Attribute &a : parseAttributes(inner)) {
+ if (a.name == QLatin1String("http-equiv")
+ && a.value.trimmed().compare(QLatin1String("refresh"),
+ Qt::CaseInsensitive) == 0) {
+ refresh = true;
+ }
+ }
+ if (!refresh) {
+ out += m.captured(0);
+ continue;
+ }
+ }
+ found = true;
+ continue;
+ }
+
+ if (closing) {
+ out += m.captured(0);
+ continue;
+ }
+
+ // Rebuild the tag from the attributes that survive.
+ QStringList kept;
+ bool dropped = false;
+ for (const Attribute &a : parseAttributes(inner)) {
+ if (attributeIsUnsafe(a)) {
+ dropped = true;
+ continue;
+ }
+ kept.append(a.raw);
+ }
+
+ if (dropped)
+ found = true;
+
+ // An <img> whose src was the thing removed would render as a broken
+ // image icon in the recipient's client, which is noisier than the gap
+ // the design asks for. Drop the element instead, and only when it has
+ // nothing left to show.
+ if (dropped && name == QLatin1String("img")) {
+ bool hasSrc = false;
+ for (const QString &k : kept) {
+ if (k.startsWith(QLatin1String("src"), Qt::CaseInsensitive))
+ hasSrc = true;
+ }
+ if (!hasSrc)
+ continue;
+ }
+
+ QString rebuilt = QStringLiteral("<") + m.captured(2);
+ if (!kept.isEmpty())
+ rebuilt += QLatin1Char(' ') + kept.join(QLatin1Char(' '));
+ if (inner.trimmed().endsWith(QLatin1Char('/')))
+ rebuilt += QLatin1Char('/');
+ rebuilt += QLatin1Char('>');
+ out += rebuilt;
+ }
+
+ out += html.mid(pos);
+
+ if (foundOut)
+ *foundOut = found;
+ return out;
+}
+
+} // namespace
+
+QString HtmlSanitiser::stripRemoteContent(const QString &html)
+{
+ return walk(html, nullptr);
+}
+
+bool HtmlSanitiser::hasRemoteContent(const QString &html)
+{
+ bool found = false;
+ walk(html, &found);
+ return found;
+}
diff --git a/src/htmlsanitiser.h b/src/htmlsanitiser.h
new file mode 100644
index 0000000..52257d5
--- /dev/null
+++ b/src/htmlsanitiser.h
@@ -0,0 +1,84 @@
+/*
+ * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs
+ * Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License version 2 as
+ * published by the Free Software Foundation.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
+ */
+
+#pragma once
+
+#include <QString>
+
+/// Makes a stranger's HTML safe to put INSIDE A MESSAGE WE SEND.
+///
+/// Item 171. This is not the message pane's problem restated: the pane renders
+/// hostile markup TO THE USER, behind protections that live in `MessageView`
+/// (an off-the-record profile, JavaScript disabled, and `RequestInterceptor`
+/// blocking every request by default and failing closed). **None of those
+/// apply to a forward.** The markup leaves this process and is rendered by
+/// somebody else's client, under their policy, on their machine, and the
+/// interceptor cannot help because it intercepts requests WE would make.
+///
+/// So the sanitising happens to the bytes, before `MessageSender` sees them,
+/// and there is no second line of defence behind it. Forwarding a tracking
+/// pixel forwards the tracking: the original sender learns that the forwarded
+/// copy was opened, by whom and how often, and the recipient never agreed to
+/// that.
+///
+/// **This is an ALLOW-LIST, and the distinction from `namespaceCids()` is the
+/// whole design.** That function is a block-list: it names the attributes that
+/// can carry a `cid:` and rewrites those, and it documents scoping `srcset=`
+/// out because its quoting grammar differs. That trade is correct for
+/// rewriting and wrong here, because the two failures are not comparable:
+///
+/// | | a missed reference means |
+/// |---|---|
+/// | `namespaceCids` (rewrite) | one broken image |
+/// | this (strip) | a beacon reaching the recipient |
+///
+/// Anything not recognised is therefore REMOVED rather than kept. A new
+/// attribute, an unanticipated quoting form, a `srcset`, a CSS `image-set()`,
+/// an `@import`: each is handled by the default, not by having been enumerated
+/// in advance.
+///
+/// The invariant every test asserts, and the one to preserve under any edit:
+/// **after sanitising, no attribute value and no CSS construct contains a URL
+/// whose scheme is anything but `cid:`.**
+namespace HtmlSanitiser {
+
+/// \p html with every remote-fetching construct removed.
+///
+/// `cid:` references are KEPT: they travel inside the message, fetch nothing,
+/// and are what lets an inline logo survive a forward. Structural and
+/// presentational markup is kept too, `style=""` included, with its remote
+/// constructs removed.
+///
+/// Removed: any attribute value carrying a non-`cid:` URL (`http:`, `https:`,
+/// protocol-relative `//host/path`, `data:`, `file:`); the elements that exist
+/// to fetch or redirect (`link`, `script`, `iframe`, `object`, `embed`,
+/// `base`, and `meta http-equiv="refresh"`); CSS `url()`, `@import` and
+/// `image-set()` naming anything but a `cid:`; and event-handler attributes.
+///
+/// A stripped `<img>` leaves a gap. That is correct, and must not be papered
+/// over with a placeholder that itself fetches.
+QString stripRemoteContent(const QString &html);
+
+/// True when \p html carries anything `stripRemoteContent()` would remove.
+///
+/// Drives the composer's control: the checkbox is only worth showing for a
+/// message that actually has remote content. Never used to DECIDE whether to
+/// strip, only whether to offer the choice.
+bool hasRemoteContent(const QString &html);
+
+} // namespace HtmlSanitiser
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
diff --git a/src/types.h b/src/types.h
index c78ab76..8e24cd1 100644
--- a/src/types.h
+++ b/src/types.h
@@ -378,6 +378,21 @@ struct OutgoingMessage
QStringList attachments; ///< Local paths, read at build time.
QString inReplyTo;
QStringList references;
+
+ /// Item 171. The forwarded original's HTML, already sanitised, appended to
+ /// the HTML alternative below the user's own text.
+ ///
+ /// Empty for everything but a Forward of a message that had an HTML part.
+ /// Carrying it here rather than re-parsing at build time keeps
+ /// MessageBuilder a pure function of this struct, which is what lets the
+ /// MIME nesting be tested without a file on disk.
+ ///
+ /// **Sanitised by the CALLER**, with HtmlSanitiser::stripRemoteContent(),
+ /// because whether to strip is the user's per-forward choice and a
+ /// builder cannot see a checkbox. The one exception to that rule is the
+ /// user deliberately unchecking it.
+ QString forwardedHtml;
+
};
Q_DECLARE_METATYPE(ThreadSummary)