summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/CMakeLists.txt2
-rw-r--r--src/messageview.cpp227
-rw-r--r--src/messageview.h66
-rw-r--r--src/threadcidmap.cpp51
-rw-r--r--src/threadcidmap.h29
5 files changed, 375 insertions, 0 deletions
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
index 6a2b18e..0073870 100644
--- a/src/CMakeLists.txt
+++ b/src/CMakeLists.txt
@@ -8,6 +8,8 @@ add_library(qtmaildir_lib STATIC
notmuchworker.cpp
threadlistmodel.cpp
mailsync.cpp
+ threadcidmap.cpp
+ messageview.cpp
)
target_include_directories(qtmaildir_lib
diff --git a/src/messageview.cpp b/src/messageview.cpp
new file mode 100644
index 0000000..4e7586e
--- /dev/null
+++ b/src/messageview.cpp
@@ -0,0 +1,227 @@
+#include "messageview.h"
+
+#include <QDesktopServices>
+#include <QHBoxLayout>
+#include <QLabel>
+#include <QPushButton>
+#include <QTimer>
+#include <QVBoxLayout>
+#include <QWebEnginePage>
+#include <QWebEngineProfile>
+#include <QWebEngineSettings>
+#include <QWebEngineView>
+
+#include <algorithm>
+
+#include "cidschemehandler.h"
+#include "htmlbuilder.h"
+#include "requestinterceptor.h"
+#include "threadcidmap.h"
+
+namespace {
+
+/// Intercepts link clicks so a message can never navigate the pane.
+class MessagePage : public QWebEnginePage
+{
+public:
+ MessagePage(QWebEngineProfile *profile, QObject *parent)
+ : QWebEnginePage(profile, parent) {}
+
+protected:
+ bool acceptNavigationRequest(const QUrl &url, NavigationType type,
+ bool isMainFrame) override
+ {
+ // setHtml() arrives as a typed navigation to our own base URL. Matching
+ // the exact URL rather than the scheme keeps this consistent with the
+ // interceptor, which deliberately refuses to trust qtmaildir: wholesale.
+ if (type == NavigationTypeTyped && url == MessageView::documentUrl())
+ return true;
+
+ if (type == NavigationTypeLinkClicked) {
+ QDesktopServices::openUrl(url);
+ return false;
+ }
+
+ // Subframe loads are still subject to the interceptor; a main-frame
+ // navigation would replace the pane, which no message may do.
+ return !isMainFrame;
+ }
+};
+
+} // namespace
+
+MessageView::MessageView(QWidget *parent)
+ : QWidget(parent)
+{
+ // Off-the-record profile: no cookies, no cache, nothing persisted.
+ m_profile = new QWebEngineProfile(this);
+ m_profile->setHttpCacheType(QWebEngineProfile::NoCache);
+ m_profile->setPersistentCookiesPolicy(QWebEngineProfile::NoPersistentCookies);
+
+ m_interceptor = new RequestInterceptor(this);
+ m_profile->setUrlRequestInterceptor(m_interceptor);
+
+ m_cidHandler = new CidSchemeHandler(this);
+ m_profile->installUrlSchemeHandler(QByteArrayLiteral("cid"), m_cidHandler);
+
+ m_view = new QWebEngineView(this);
+ m_view->setPage(new MessagePage(m_profile, m_view));
+
+ QWebEngineSettings *settings = m_view->settings();
+ settings->setAttribute(QWebEngineSettings::JavascriptEnabled, false);
+ settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false);
+ settings->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
+ settings->setAttribute(QWebEngineSettings::PluginsEnabled, false);
+ settings->setAttribute(QWebEngineSettings::FullScreenSupportEnabled, false);
+
+ m_headerLabel = new QLabel(this);
+ m_headerLabel->setTextFormat(Qt::RichText);
+ m_headerLabel->setWordWrap(true);
+ m_headerLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
+
+ m_blockedLabel = new QLabel(tr("Remote content blocked"), this);
+ m_loadRemoteButton = new QPushButton(tr("Load remote content"), this);
+ connect(m_loadRemoteButton, &QPushButton::clicked,
+ this, &MessageView::loadRemoteContent);
+
+ auto *blockedRow = new QHBoxLayout;
+ blockedRow->addWidget(m_blockedLabel);
+ blockedRow->addWidget(m_loadRemoteButton);
+ blockedRow->addStretch();
+
+ m_attachmentBar = new QWidget(this);
+ new QHBoxLayout(m_attachmentBar);
+
+ auto *layout = new QVBoxLayout(this);
+ layout->addWidget(m_headerLabel);
+ layout->addLayout(blockedRow);
+ layout->addWidget(m_view, 1);
+ layout->addWidget(m_attachmentBar);
+
+ clear();
+}
+
+MessageView::~MessageView() = default;
+
+/// The single place that loads a document into the view.
+///
+/// RequestInterceptor trusts exactly one qtmaildir: URL and denies every other
+/// URL on that scheme, so the base URL given to setHtml() and the one given to
+/// setDocumentUrl() must be identical. Routing every load through here is what
+/// makes that true by construction rather than by remembering to pair two calls
+/// at each site.
+void MessageView::setDocument(const QString &html)
+{
+ m_interceptor->setDocumentUrl(documentUrl());
+ m_view->setHtml(html, documentUrl());
+}
+
+void MessageView::clear()
+{
+ m_items.clear();
+
+ // No thread is displayed, so nothing may be served or allowed. Without
+ // this, the previous thread's parts would stay reachable.
+ m_cidHandler->setParts({});
+ m_interceptor->setAllowedCids({});
+ m_interceptor->resetForNewMessage();
+
+ setDocument(QString());
+ m_headerLabel->clear();
+ m_blockedLabel->hide();
+ m_loadRemoteButton->hide();
+}
+
+void MessageView::showThread(const QList<ThreadRenderItem> &items)
+{
+ m_items = items;
+ m_preferHtml = true;
+
+ // Every thread starts from a clean policy: no remote grant carries over.
+ m_interceptor->resetForNewMessage();
+
+ // Two messages in one thread commonly share a Content-ID, and the thread is
+ // one document, so the parts are namespaced per message.
+ const ThreadCidMap cidMap = buildThreadCidMap(m_items);
+ m_interceptor->setAllowedCids(cidMap.allowedCids);
+ m_cidHandler->setParts(cidMap.parts);
+
+ updateHeader();
+ render();
+}
+
+void MessageView::showError(const QString &text, const QString &filePath)
+{
+ m_items.clear();
+
+ // An error card references nothing, so the policy is emptied rather than
+ // left holding the previous thread's parts.
+ m_cidHandler->setParts({});
+ m_interceptor->setAllowedCids({});
+ m_interceptor->resetForNewMessage();
+
+ m_headerLabel->setText(tr("<b>Cannot display message</b>"));
+ m_blockedLabel->hide();
+ m_loadRemoteButton->hide();
+
+ const QString html = QStringLiteral(
+ "<html><body><p>%1</p><p><code>%2</code></p></body></html>")
+ .arg(text.toHtmlEscaped(), filePath.toHtmlEscaped());
+ setDocument(html);
+}
+
+void MessageView::updateHeader()
+{
+ if (m_items.isEmpty()) {
+ m_headerLabel->clear();
+ return;
+ }
+
+ // The thread's subject comes from its first message; later replies carry
+ // Re: prefixes that add nothing.
+ const QString subject = m_items.first().message.subject;
+
+ m_headerLabel->setText(
+ QStringLiteral("<b>%1</b><br><small>%2</small>")
+ .arg(subject.toHtmlEscaped(),
+ tr("%n message(s) in thread", "", m_items.size())));
+}
+
+void MessageView::render()
+{
+ const HtmlBuilder::Mode mode =
+ m_preferHtml ? HtmlBuilder::PreferHtml : HtmlBuilder::ForcePlain;
+
+ setDocument(HtmlBuilder::buildThread(m_items, mode));
+
+ // Blocking is discovered during load, so check shortly afterwards.
+ QTimer::singleShot(300, this, [this]() {
+ const bool blocked = m_interceptor->blockedAnything()
+ && !m_interceptor->allowRemote();
+ m_blockedLabel->setVisible(blocked);
+ m_loadRemoteButton->setVisible(blocked);
+ });
+}
+
+void MessageView::toggleHtml()
+{
+ const bool anyHtml = std::any_of(
+ m_items.cbegin(), m_items.cend(),
+ [](const ThreadRenderItem &item) { return item.message.hasHtml(); });
+
+ if (!anyHtml) {
+ emit statusMessage(tr("No message in this thread has an HTML part"));
+ return;
+ }
+ m_preferHtml = !m_preferHtml;
+ render();
+}
+
+void MessageView::loadRemoteContent()
+{
+ // Applies to this thread only and is cleared by the next showThread().
+ m_interceptor->setAllowRemote(true);
+ m_blockedLabel->hide();
+ m_loadRemoteButton->hide();
+ render();
+}
diff --git a/src/messageview.h b/src/messageview.h
new file mode 100644
index 0000000..9f2edb3
--- /dev/null
+++ b/src/messageview.h
@@ -0,0 +1,66 @@
+#pragma once
+
+#include <QList>
+#include <QUrl>
+#include <QWidget>
+
+#include "htmlbuilder.h"
+#include "mimeparser.h"
+
+class QLabel;
+class QPushButton;
+class QWebEngineView;
+class QWebEngineProfile;
+class CidSchemeHandler;
+class RequestInterceptor;
+
+/// The message pane: thread header, body, attachment bar.
+///
+/// A whole thread renders into one web view. A newsletter thread can hold
+/// dozens of messages, and one view per message would spawn one Chromium
+/// render process per message.
+class MessageView : public QWidget
+{
+ Q_OBJECT
+public:
+ explicit MessageView(QWidget *parent = nullptr);
+ ~MessageView() override;
+
+ /// The base URL every document in this pane is loaded with, and the only
+ /// qtmaildir: URL the interceptor trusts. Defined once so setHtml() and
+ /// setDocumentUrl() cannot drift apart: if they ever disagree, the
+ /// interceptor fails closed and the pane renders nothing at all.
+ static QUrl documentUrl() { return QUrl(QStringLiteral("qtmaildir://message")); }
+
+ /// Renders a whole thread, oldest first. Items whose expanded flag is
+ /// false collapse to a one-line stub.
+ void showThread(const QList<ThreadRenderItem> &items);
+
+ void showError(const QString &text, const QString &filePath);
+ void clear();
+
+public slots:
+ void toggleHtml();
+ void loadRemoteContent();
+
+signals:
+ void statusMessage(const QString &text);
+
+private:
+ void render();
+ void updateHeader();
+ void setDocument(const QString &html);
+
+ QList<ThreadRenderItem> m_items;
+ bool m_preferHtml = true;
+
+ QWebEngineProfile *m_profile = nullptr;
+ QWebEngineView *m_view = nullptr;
+ RequestInterceptor *m_interceptor = nullptr;
+ CidSchemeHandler *m_cidHandler = nullptr;
+
+ QLabel *m_headerLabel = nullptr;
+ QLabel *m_blockedLabel = nullptr;
+ QPushButton *m_loadRemoteButton = nullptr;
+ QWidget *m_attachmentBar = nullptr;
+};
diff --git a/src/threadcidmap.cpp b/src/threadcidmap.cpp
new file mode 100644
index 0000000..c6f1cc7
--- /dev/null
+++ b/src/threadcidmap.cpp
@@ -0,0 +1,51 @@
+#include "threadcidmap.h"
+
+#include "cidschemehandler.h"
+
+namespace {
+
+/// Removes '!' from a prefix without ever mapping two distinct prefixes onto
+/// one another.
+///
+/// A plain replace of '!' with '_' is NOT safe here: "m0!x" and "m0_x" would
+/// both become "m0_x", merging two messages into one key space, which is
+/// precisely the collision the namespacing exists to prevent. Escaping the
+/// escape character first keeps the transform injective: '_' doubles, and '!'
+/// becomes "_x", so no output is reachable from two different inputs.
+QString sanitizePrefix(const QString &prefix)
+{
+ if (!prefix.contains(QLatin1Char('!')) && !prefix.contains(QLatin1Char('_')))
+ return prefix;
+
+ QString result;
+ result.reserve(prefix.size() + 4);
+ for (const QChar c : prefix) {
+ if (c == QLatin1Char('_'))
+ result += QLatin1String("__");
+ else if (c == QLatin1Char('!'))
+ result += QLatin1String("_x");
+ else
+ result += c;
+ }
+ return result;
+}
+
+} // namespace
+
+ThreadCidMap buildThreadCidMap(const QList<ThreadRenderItem> &items)
+{
+ ThreadCidMap map;
+
+ for (const ThreadRenderItem &item : items) {
+ const QString prefix = sanitizePrefix(item.cidPrefix);
+
+ for (auto it = item.message.inlineParts.cbegin();
+ it != item.message.inlineParts.cend(); ++it) {
+ const QString key = CidSchemeHandler::namespacedKey(prefix, it.key());
+ map.parts.insert(key, it.value());
+ map.allowedCids.insert(key);
+ }
+ }
+
+ return map;
+}
diff --git a/src/threadcidmap.h b/src/threadcidmap.h
new file mode 100644
index 0000000..76bd8b9
--- /dev/null
+++ b/src/threadcidmap.h
@@ -0,0 +1,29 @@
+#pragma once
+
+#include <QHash>
+#include <QList>
+#include <QSet>
+#include <QString>
+
+#include "htmlbuilder.h"
+#include "mimeparser.h"
+
+/// The inline parts of a whole thread, keyed by their namespaced cid.
+struct ThreadCidMap
+{
+ QHash<QString, InlinePart> parts; ///< For the scheme handler.
+ QSet<QString> allowedCids; ///< For the interceptor. Same keys.
+};
+
+/// Flattens every message's inline parts into one namespaced map.
+///
+/// Two messages in a thread commonly share a Content-ID (cid:logo@example.org
+/// from the same sender's newsletter template), and the thread renders as one
+/// document, so the raw ids would collide and one message would show another's
+/// image. Keys are "<prefix>!<content-id>".
+///
+/// A cidPrefix containing '!' would break the split that keeps those apart, so
+/// it is sanitized here rather than trusted: Q_ASSERT fires in debug builds but
+/// is compiled out in release, and this map decides which bytes a message can
+/// name. Sanitizing is injective, so two distinct prefixes stay distinct.
+ThreadCidMap buildThreadCidMap(const QList<ThreadRenderItem> &items);