diff options
| author | Danilo M. <danix@danix.xyz> | 2026-08-02 17:46:21 +0200 |
|---|---|---|
| committer | Danilo M. <danix@danix.xyz> | 2026-08-02 17:46:21 +0200 |
| commit | 2a10cebcfb978ce7c5f03a97473eafe9bb3d15dd (patch) | |
| tree | faec2dc7b5d9be72fac5e091b81eed3491d3c2d6 | |
| parent | 8ecd5a8e83380f543b856ff28226a8e15fd7dc96 (diff) | |
| download | qtmaildir-2a10cebcfb978ce7c5f03a97473eafe9bb3d15dd.tar.gz qtmaildir-2a10cebcfb978ce7c5f03a97473eafe9bb3d15dd.zip | |
feat: add HTML builder and cid: scheme handler
HtmlBuilder renders parsed messages (and whole threads, as one document,
so newsletter threads don't spawn one Chromium process per message) into
the HTML string the web view loads. Plain text is escaped and quote lines
marked; the cid: rewrite is namespaced per message ("<prefix>!<id>") so
two thread messages sharing a Content-ID don't collide.
Hardened namespaceCids beyond the initial sketch after attacking it:
handles unquoted cid: attribute values, background=/poster= (not just
src/href), and CSS url(cid:...) in both style="" attributes and <style>
blocks, all case-insensitively. Replaced the greedy [^"']+ capture with
per-quote-style alternation so two cid: refs on one line can't bleed into
each other.
CidSchemeHandler serves cid: requests from the thread's inline-parts map,
keyed by the same namespaced string, replaced wholesale per thread.
| -rw-r--r-- | src/CMakeLists.txt | 2 | ||||
| -rw-r--r-- | src/cidschemehandler.cpp | 36 | ||||
| -rw-r--r-- | src/cidschemehandler.h | 30 | ||||
| -rw-r--r-- | src/htmlbuilder.cpp | 219 | ||||
| -rw-r--r-- | src/htmlbuilder.h | 50 | ||||
| -rw-r--r-- | tests/CMakeLists.txt | 1 | ||||
| -rw-r--r-- | tests/test_htmlbuilder.cpp | 206 |
7 files changed, 544 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index e9da44e..fc368cb 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -3,6 +3,8 @@ add_library(qtmaildir_lib STATIC config.cpp mimeparser.cpp requestinterceptor.cpp + htmlbuilder.cpp + cidschemehandler.cpp ) target_include_directories(qtmaildir_lib diff --git a/src/cidschemehandler.cpp b/src/cidschemehandler.cpp new file mode 100644 index 0000000..b4bb836 --- /dev/null +++ b/src/cidschemehandler.cpp @@ -0,0 +1,36 @@ +#include "cidschemehandler.h" + +#include <QBuffer> +#include <QWebEngineUrlRequestJob> + +CidSchemeHandler::CidSchemeHandler(QObject *parent) + : QWebEngineUrlSchemeHandler(parent) +{ +} + +void CidSchemeHandler::requestStarted(QWebEngineUrlRequestJob *job) +{ + // QUrl::path() returns the percent-DECODED body of a cid: URL (confirmed + // in Task 5's RequestInterceptor tests, and re-verified here for the + // namespaced "<prefix>!<id>" form specifically: '!' needs no percent + // escaping per RFC 3986 sub-delims, and even when a Content-ID's own + // characters are percent-encoded by the sender, decoding is idempotent + // with how the prefix was concatenated in HtmlBuilder::namespaceCids, so + // the string handed to path() here is exactly the map key that was + // inserted for this part). + const QString id = job->requestUrl().path(); + + if (!m_parts.contains(id)) { + job->fail(QWebEngineUrlRequestJob::UrlNotFound); + return; + } + + const InlinePart part = m_parts.value(id); + + // The buffer is parented to the job so it lives exactly as long as needed. + auto *buffer = new QBuffer(job); + buffer->setData(part.data); + buffer->open(QIODevice::ReadOnly); + + job->reply(part.mimeType.toUtf8(), buffer); +} diff --git a/src/cidschemehandler.h b/src/cidschemehandler.h new file mode 100644 index 0000000..56e6ea1 --- /dev/null +++ b/src/cidschemehandler.h @@ -0,0 +1,30 @@ +#pragma once + +#include <QHash> +#include <QWebEngineUrlSchemeHandler> + +#include "mimeparser.h" + +/// Serves cid: URLs from the currently displayed thread only. +/// +/// Keys are the namespaced form "<prefix>!<content-id>" produced by +/// HtmlBuilder, so two messages in one thread that share a Content-ID do not +/// collide. The map is replaced wholesale on every thread change, so a thread +/// can never reference another thread's parts. +class CidSchemeHandler : public QWebEngineUrlSchemeHandler +{ + Q_OBJECT +public: + explicit CidSchemeHandler(QObject *parent = nullptr); + + void setParts(const QHash<QString, InlinePart> &parts) { m_parts = parts; } + + /// Builds the namespaced key HtmlBuilder's rewritten URLs will request. + static QString namespacedKey(const QString &prefix, const QString &contentId) + { return prefix + QLatin1Char('!') + contentId; } + + void requestStarted(QWebEngineUrlRequestJob *job) override; + +private: + QHash<QString, InlinePart> m_parts; +}; diff --git a/src/htmlbuilder.cpp b/src/htmlbuilder.cpp new file mode 100644 index 0000000..1830383 --- /dev/null +++ b/src/htmlbuilder.cpp @@ -0,0 +1,219 @@ +#include "htmlbuilder.h" + +#include <QRegularExpression> + +namespace { + +const char *kStyle = R"CSS( +body { font-family: sans-serif; font-size: 10pt; margin: 12px; } +pre.plain { white-space: pre-wrap; word-wrap: break-word; + font-family: monospace; margin: 0; } +span.quote { color: #4a6f8a; } +.message { border-top: 1px solid #bbb; padding: 10px 0; } +.message:first-child { border-top: none; } +.msg-header { font-size: 9pt; color: #555; margin-bottom: 8px; } +.msg-header .who { font-weight: bold; color: #000; } +.stub { font-size: 9pt; color: #666; padding: 4px 0; + border-top: 1px solid #ddd; } +)CSS"; + +} // namespace + +QString HtmlBuilder::renderPlain(const QString &text) +{ + QString out; + out += QStringLiteral("<pre class=\"plain\">"); + + const QStringList lines = text.split(QLatin1Char('\n')); + for (int i = 0; i < lines.size(); ++i) { + const QString &line = lines.at(i); + const bool quoted = line.startsWith(QLatin1Char('>')); + + if (quoted) + out += QStringLiteral("<span class=\"quote\">"); + out += line.toHtmlEscaped(); + if (quoted) + out += QStringLiteral("</span>"); + + if (i + 1 < lines.size()) + out += QLatin1Char('\n'); + } + + out += QStringLiteral("</pre>"); + return out; +} + +QString HtmlBuilder::document(const QString &bodyHtml) +{ + return QStringLiteral( + "<!DOCTYPE html><html><head><meta charset=\"utf-8\">" + "<style>%1</style></head><body>%2</body></html>") + .arg(QString::fromUtf8(kStyle), bodyHtml); +} + +QString HtmlBuilder::namespaceCids(const QString &html, const QString &prefix) +{ + if (prefix.isEmpty()) + return html; + + // This runs on the sender's raw, unescaped HTML markup (not on text that + // has been through toHtmlEscaped()), so no double-escaping happens here; + // it is purely a URL rewrite over the existing markup. + // + // Two independent patterns are needed: + // + // 1. Attribute values: src=, href=, background=, poster= (the common + // real-world attributes that can carry a cid: reference in HTML + // email), quoted with " or ', quoted the other way, or entirely + // unquoted (<img src=cid:x> is valid HTML and unquoted references are + // seen in the wild). Attribute name is matched case-insensitively and + // whitespace/newlines are tolerated around '='. + // + // 2. CSS url(cid:...): appears both inside a style="" attribute value + // and inside a <style> block, quoted or unquoted. This is handled as + // a separate pass since it has different quoting/terminator rules + // (a bare url(...) form terminates on ')', not on a matching quote). + // + // srcset= is not handled: it is a list of URL/descriptor pairs with a + // different quoting grammar, and cid: URLs in srcset are not something + // real-world mail has been observed to use; treating it is out of scope + // for this task. + QString out = html; + + { + // (?:"([^"]*)"|'([^']*)'|([^\s"'<>]+)) picks the correctly-bounded + // value for whichever quoting style is used, so a quoted value can + // never be captured past its own closing quote (fixing the greedy + // [^"']+ that the naive version used, which is provably safe here + // since each alternative's character class already excludes its own + // terminator). capturedStart(N) (rather than testing the captured + // text for emptiness) is what disambiguates which alternative + // matched, so an empty-but-present cid ("cid:\"\"") is handled + // correctly too. + static const QRegularExpression re( + QStringLiteral( + "\\b(src|href|background|poster)" + "\\s*=\\s*" + "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^\\s\"'<>]+))"), + QRegularExpression::CaseInsensitiveOption); + + QString rewritten; + qsizetype last = 0; + auto it = re.globalMatch(out); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + QString id; + QChar quote; + if (m.capturedStart(2) != -1) { + id = m.captured(2); + quote = QLatin1Char('"'); + } else if (m.capturedStart(3) != -1) { + id = m.captured(3); + quote = QLatin1Char('\''); + } else { + id = m.captured(4); + } + + rewritten += out.mid(last, m.capturedStart() - last); + rewritten += m.captured(1); + rewritten += QStringLiteral("="); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id); + if (!quote.isNull()) + rewritten += quote; + last = m.capturedEnd(); + } + rewritten += out.mid(last); + out = rewritten; + } + + { + // CSS url(cid:...), quoted (' or ") or bare, inside a style="" value + // or a <style> block. Bare form terminates at ')'. + static const QRegularExpression re( + QStringLiteral( + "url\\(\\s*" + "(?:\"cid:([^\"]*)\"|'cid:([^']*)'|cid:([^)\\s]+))" + "\\s*\\)"), + QRegularExpression::CaseInsensitiveOption); + + QString rewritten; + qsizetype last = 0; + auto it = re.globalMatch(out); + while (it.hasNext()) { + const QRegularExpressionMatch m = it.next(); + QString id; + QChar quote; + if (m.capturedStart(1) != -1) { + id = m.captured(1); + quote = QLatin1Char('"'); + } else if (m.capturedStart(2) != -1) { + id = m.captured(2); + quote = QLatin1Char('\''); + } else { + id = m.captured(3); + } + + rewritten += out.mid(last, m.capturedStart() - last); + rewritten += QStringLiteral("url("); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral("cid:%1!%2").arg(prefix, id); + if (!quote.isNull()) + rewritten += quote; + rewritten += QStringLiteral(")"); + last = m.capturedEnd(); + } + rewritten += out.mid(last); + out = rewritten; + } + + return out; +} + +QString HtmlBuilder::renderBody(const ThreadRenderItem &item, Mode mode) +{ + if (mode == PreferHtml && item.message.hasHtml()) + return namespaceCids(item.message.htmlBody, item.cidPrefix); + return renderPlain(item.message.plainBody); +} + +QString HtmlBuilder::renderStub(const ParsedMessage &message) +{ + return QStringLiteral("<div class=\"stub\">%1 — %2</div>") + .arg(message.from.toHtmlEscaped(), message.subject.toHtmlEscaped()); +} + +QString HtmlBuilder::build(const ParsedMessage &message, Mode mode) +{ + ThreadRenderItem item; + item.message = message; + item.expanded = true; + return document(renderBody(item, mode)); +} + +QString HtmlBuilder::buildThread(const QList<ThreadRenderItem> &items, Mode mode) +{ + QString body; + + for (int i = 0; i < items.size(); ++i) { + const ThreadRenderItem &item = items.at(i); + + if (!item.expanded) { + body += renderStub(item.message); + continue; + } + + body += QStringLiteral( + "<div class=\"message\" id=\"msg-%1\">" + "<div class=\"msg-header\"><span class=\"who\">%2</span><br>%3</div>" + "%4</div>") + .arg(QString::number(i), + item.message.from.toHtmlEscaped(), + item.message.date.toHtmlEscaped(), + renderBody(item, mode)); + } + + return document(body); +} diff --git a/src/htmlbuilder.h b/src/htmlbuilder.h new file mode 100644 index 0000000..ac24e28 --- /dev/null +++ b/src/htmlbuilder.h @@ -0,0 +1,50 @@ +#pragma once + +#include <QList> + +#include "mimeparser.h" + +/// One message's place in a rendered thread. +struct ThreadRenderItem +{ + ParsedMessage message; + + /// Matched messages render in full; unmatched collapse to a one-line stub. + bool expanded = true; + + /// Disambiguates cid: references. Two newsletters in one thread commonly + /// use the same Content-ID (cid:logo@example.org), which would collide in + /// a single document, so every reference is rewritten to + /// cid:<prefix>!<id>. + QString cidPrefix; +}; + +/// Turns parsed messages into the HTML string handed to the web view. +/// +/// Plain text goes through the same path as HTML so the view has one render +/// path rather than two. A whole thread renders as ONE document rather than one +/// view per message: a thread of newsletters can hold dozens of messages, and a +/// QWebEngineView each would spawn a Chromium render process each. +class HtmlBuilder +{ +public: + enum Mode { + PreferHtml, ///< Use the HTML part when the message has one. + ForcePlain, ///< Always render the plain part, escaped. + }; + + /// Single message, used for the error card and for tests. + static QString build(const ParsedMessage &message, Mode mode); + + /// The whole thread, oldest first. + static QString buildThread(const QList<ThreadRenderItem> &items, Mode mode); + + /// Rewrites cid: URLs in an HTML body to their namespaced form. + static QString namespaceCids(const QString &html, const QString &prefix); + +private: + static QString renderPlain(const QString &text); + static QString renderBody(const ThreadRenderItem &item, Mode mode); + static QString renderStub(const ParsedMessage &message); + static QString document(const QString &bodyHtml); +}; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index cab8d6c..d0faeec 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -11,3 +11,4 @@ add_qtmaildir_test(mimeparser) target_compile_definitions(test_mimeparser PRIVATE FIXTURE_DIR="${CMAKE_CURRENT_SOURCE_DIR}/fixtures") add_qtmaildir_test(interceptor) +add_qtmaildir_test(htmlbuilder) diff --git a/tests/test_htmlbuilder.cpp b/tests/test_htmlbuilder.cpp new file mode 100644 index 0000000..38699f4 --- /dev/null +++ b/tests/test_htmlbuilder.cpp @@ -0,0 +1,206 @@ +#include <QtTest> +#include "htmlbuilder.h" + +class TestHtmlBuilder : public QObject +{ + Q_OBJECT +private slots: + void escapesPlainText(); + void preservesHtmlBodyWhenHtmlRequested(); + void marksQuotedLines(); + void plainTextScriptTagIsNeutralised(); + void buildsThreadWithAllMessages(); + void collapsedMessageShowsStubOnly(); + void threadNamespacesCidUrls(); + + // Adversarial additions. + void namespacesUnquotedCidAttribute(); + void namespacesCaseInsensitiveAttributeName(); + void namespacesCidInBackgroundAttribute(); + void namespacesCidInInlineStyleUrl(); + void namespacesCidInStyleBlock(); + void namespacesMultipleCidRefsOnOneLine(); + void namespacesWhitespaceAroundEquals(); +}; + +void TestHtmlBuilder::escapesPlainText() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("a < b & c > d"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + QVERIFY(html.contains(QStringLiteral("a < b & c > d"))); +} + +void TestHtmlBuilder::preservesHtmlBodyWhenHtmlRequested() +{ + ParsedMessage msg; + msg.ok = true; + msg.htmlBody = QStringLiteral("<p>hello</p>"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::PreferHtml); + QVERIFY(html.contains(QStringLiteral("<p>hello</p>"))); +} + +void TestHtmlBuilder::marksQuotedLines() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("reply\n> quoted\nend"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + QVERIFY(html.contains(QStringLiteral("class=\"quote\""))); +} + +void TestHtmlBuilder::plainTextScriptTagIsNeutralised() +{ + ParsedMessage msg; + msg.ok = true; + msg.plainBody = QStringLiteral("<script>alert(1)</script>"); + + const QString html = HtmlBuilder::build(msg, HtmlBuilder::ForcePlain); + // Escaped, not embedded. (JavaScript is also disabled at the profile level, + // so this is the second of two independent defences.) + QVERIFY(!html.contains(QStringLiteral("<script>"))); + QVERIFY(html.contains(QStringLiteral("<script>"))); +} + +void TestHtmlBuilder::buildsThreadWithAllMessages() +{ + ThreadRenderItem first; + first.message.ok = true; + first.message.subject = QStringLiteral("First"); + first.message.from = QStringLiteral("Alice"); + first.message.plainBody = QStringLiteral("first body"); + first.expanded = true; + + ThreadRenderItem second; + second.message.ok = true; + second.message.subject = QStringLiteral("Second"); + second.message.from = QStringLiteral("Bob"); + second.message.plainBody = QStringLiteral("second body"); + second.expanded = true; + + const QString html = + HtmlBuilder::buildThread({ first, second }, HtmlBuilder::ForcePlain); + + QVERIFY(html.contains(QStringLiteral("first body"))); + QVERIFY(html.contains(QStringLiteral("second body"))); + // Each message is its own section, so per-message CSS and anchors work. + QCOMPARE(html.count(QStringLiteral("class=\"message\"")), 2); +} + +void TestHtmlBuilder::collapsedMessageShowsStubOnly() +{ + ThreadRenderItem item; + item.message.ok = true; + item.message.from = QStringLiteral("Carol"); + item.message.subject = QStringLiteral("Old news"); + item.message.plainBody = QStringLiteral("secret body text"); + item.expanded = false; + + const QString html = + HtmlBuilder::buildThread({ item }, HtmlBuilder::ForcePlain); + + // Unmatched messages collapse to a one-line stub; the body is not emitted. + QVERIFY(html.contains(QStringLiteral("Carol"))); + QVERIFY(!html.contains(QStringLiteral("secret body text"))); + QVERIFY(html.contains(QStringLiteral("class=\"stub\""))); +} + +void TestHtmlBuilder::threadNamespacesCidUrls() +{ + // Two messages in one document may both reference cid:logo@x. Without + // namespacing, the second would show the first's image. + ThreadRenderItem first; + first.message.ok = true; + first.message.htmlBody = + QStringLiteral("<img src=\"cid:logo@example.org\">"); + first.expanded = true; + first.cidPrefix = QStringLiteral("m0"); + + ThreadRenderItem second; + second.message.ok = true; + second.message.htmlBody = + QStringLiteral("<img src=\"cid:logo@example.org\">"); + second.expanded = true; + second.cidPrefix = QStringLiteral("m1"); + + const QString html = + HtmlBuilder::buildThread({ first, second }, HtmlBuilder::PreferHtml); + + QVERIFY(html.contains(QStringLiteral("cid:m0!logo@example.org"))); + QVERIFY(html.contains(QStringLiteral("cid:m1!logo@example.org"))); + // The bare form must not survive, or it would resolve ambiguously. + QVERIFY(!html.contains(QStringLiteral("\"cid:logo@example.org\""))); +} + +void TestHtmlBuilder::namespacesUnquotedCidAttribute() +{ + // <img src=cid:logo@example.org> is valid HTML. An unquoted reference + // that survives un-namespaced would resolve against the WRONG message's + // parts map (or none), so it must be rewritten too. + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<img src=cid:logo@example.org>"), QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!logo@example.org"))); + QVERIFY(!html.contains(QStringLiteral("src=cid:logo@example.org"))); +} + +void TestHtmlBuilder::namespacesCaseInsensitiveAttributeName() +{ + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<img SRC = \"cid:logo@example.org\">"), QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!logo@example.org"))); +} + +void TestHtmlBuilder::namespacesCidInBackgroundAttribute() +{ + // HTML email frequently sets background images via the background= + // attribute on <table>/<td>/<body>. + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<table background=\"cid:bg@example.org\">"), + QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!bg@example.org"))); +} + +void TestHtmlBuilder::namespacesCidInInlineStyleUrl() +{ + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<div style=\"background-image:url(cid:bg@example.org)\">"), + QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!bg@example.org"))); +} + +void TestHtmlBuilder::namespacesCidInStyleBlock() +{ + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<style>.logo{background:url('cid:bg@example.org')}</style>"), + QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!bg@example.org"))); +} + +void TestHtmlBuilder::namespacesMultipleCidRefsOnOneLine() +{ + // Guards against a greedy [^"']+ eating past the first closing quote. + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<img src=\"cid:a@x\"><img src=\"cid:b@x\">"), + QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!a@x"))); + QVERIFY(html.contains(QStringLiteral("cid:m0!b@x"))); + // A greedy [^"']+ would eat past the first closing quote and swallow the + // second tag's markup into the first cid value; guard against that by + // requiring the first tag to close immediately after its own value. + QVERIFY(html.contains(QStringLiteral("cid:m0!a@x\"><img"))); +} + +void TestHtmlBuilder::namespacesWhitespaceAroundEquals() +{ + const QString html = HtmlBuilder::namespaceCids( + QStringLiteral("<img src\n = \n\"cid:logo@example.org\">"), + QStringLiteral("m0")); + QVERIFY(html.contains(QStringLiteral("cid:m0!logo@example.org"))); +} + +QTEST_MAIN(TestHtmlBuilder) +#include "test_htmlbuilder.moc" |
