aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/cidschemehandler.cpp36
-rw-r--r--src/cidschemehandler.h30
-rw-r--r--src/htmlbuilder.cpp219
-rw-r--r--src/htmlbuilder.h50
-rw-r--r--tests/CMakeLists.txt1
-rw-r--r--tests/test_htmlbuilder.cpp206
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 &mdash; %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 &lt; b &amp; c &gt; 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("&lt;script&gt;")));
+}
+
+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"