diff options
Diffstat (limited to 'src')
| -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 |
5 files changed, 337 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); +}; |
