/* * qtmaildir - a Qt6 mail client for notmuch-indexed Maildirs * Copyright (C) 2026 Danilo M. * * 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 "messageview.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "cidschemehandler.h" #include "htmlbuilder.h" #include "requestinterceptor.h" #include "tagstrip.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() does NOT navigate to the base URL it is given: it // navigates to a data: URL carrying the markup, and applies the base // URL afterwards as the document's origin. Verified empirically on Qt // 6.11; an earlier version of this function compared against // documentUrl() here and rejected every document load, so nothing // rendered at all. // // A typed main-frame navigation is therefore one we initiated // ourselves, and is accepted on that basis. This is not the security // boundary: RequestInterceptor still vets every request the document // goes on to make, including the qtmaildir: origin itself. if (type == NavigationTypeTyped && isMainFrame) 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); // Ctrl+wheel zoom. The filter goes on the application rather than on // m_view: the wheel event is delivered to an internal QQuickWidget the // view creates lazily, so there is no child to filter at this point and a // filter on m_view itself would never see it. eventFilter() narrows by // ancestry, so no event outside this pane is touched. qApp->installEventFilter(this); 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); // Tags live under the message rather than in the thread list, where // spelling them out cost most of the list's width. m_tagStrip = new TagStrip(this); m_tagStrip->hide(); auto *layout = new QVBoxLayout(this); layout->addWidget(m_headerLabel); layout->addLayout(blockedRow); layout->addWidget(m_view, 1); layout->addWidget(m_attachmentBar); layout->addWidget(m_tagStrip); clear(); } MessageView::~MessageView() = default; void MessageView::setTagColors(const TagColors *colours) { m_tagStrip->setTagColors(colours); } void MessageView::setTags(const QStringList &tags) { m_tagStrip->setTags(tags); } /// 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(); m_tagStrip->setTags({}); // 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(); // clear() does not go through render(), so the bar has to be emptied // here or the previous thread's attachments stay offered. rebuildAttachmentBar(); } void MessageView::showThread(const QList &items) { m_items = items; m_preferHtml = true; // Every thread starts from a clean policy: no remote grant carries over. m_interceptor->resetForNewMessage(); // Resetting the policy is not enough on its own. Anything fetched under a // previous grant stays in the engine's caches, and a cached resource is // painted without the interceptor being consulted at all, so returning to // a thread would show its remote images again with the grant switched off. // The policy would be right and the pane would still be lying. // // clearHttpCache() empties the profile's store, but the render process // keeps its own decoded-image cache keyed on the document, and that one // outlives a setHtml() of the same URL. Loading about:blank first discards // the previous document entirely, which is what actually drops those // images. Verified against a local server: the image is fetched once under // the grant and never re-fetched afterwards, so anything still visible on // return could only have come from that cache. // // This belongs here rather than in render(): render() also runs for the // remote-content grant itself, where throwing the document away would // discard exactly what the user just asked to see. m_profile->clearHttpCache(); m_view->setUrl(QUrl(QStringLiteral("about:blank"))); // 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("Cannot display message")); m_blockedLabel->hide(); m_loadRemoteButton->hide(); const QString html = QStringLiteral( "

%1

%2

") .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("%1
%2") .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)); rebuildAttachmentBar(); // 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); }); } QList MessageView::allAttachments() const { QList all; for (const ThreadRenderItem &item : m_items) all.append(item.message.attachments); return all; } void MessageView::rebuildAttachmentBar() { auto *layout = qobject_cast(m_attachmentBar->layout()); // Rebuilt rather than updated: a thread can change under the same widget // (toggle_html re-renders, and the next thread reuses this bar), and a // stale button would offer a save from the message before it. while (QLayoutItem *item = layout->takeAt(0)) { delete item->widget(); delete item; } const int total = allAttachments().size(); if (total == 0) { m_attachmentBar->hide(); return; } // One button whatever the count. A button per attachment made the bar as // wide as the window on a thread with fifteen of them, which pushed the // splitter over and left the thread list a few pixels wide. auto *button = new QPushButton(tr("Attachments (%1)...").arg(total), m_attachmentBar); button->setToolTip(tr("List the attachments in this thread")); connect(button, &QPushButton::clicked, this, &MessageView::showAttachmentDialog); layout->addWidget(button); layout->addStretch(); m_attachmentBar->show(); } void MessageView::showAttachmentDialog() { QDialog dialog(this); dialog.setWindowTitle(tr("Attachments")); auto *layout = new QVBoxLayout(&dialog); auto *list = new QTreeWidget(&dialog); list->setColumnCount(4); // The fourth column holds the per-row Save button and needs no label. list->setHeaderLabels({ tr("Message"), tr("File"), tr("Size"), QString() }); list->setRootIsDecorated(false); list->setSelectionMode(QAbstractItemView::NoSelection); // A thread renders as one document, so the message number is what says // which of them a file came from. for (int index = 0; index < m_items.size(); ++index) { const ParsedMessage &message = m_items.at(index).message; for (const Attachment &attachment : message.attachments) { auto *row = new QTreeWidgetItem(list); row->setText(0, QString::number(index + 1)); // safeFilename(), never the raw filename: the name in a message is // attacker-controlled and may carry separators or "..". row->setText(1, attachment.safeFilename()); row->setText(2, QLocale().formattedDataSize(attachment.data.size())); auto *save = new QPushButton(tr("Save..."), list); // Copied into the lambda: m_items is replaced wholesale by the // next showThread(), so a reference would dangle. connect(save, &QPushButton::clicked, this, [this, attachment]() { saveAttachment(attachment); }); list->setItemWidget(row, 3, save); } } for (int column = 0; column < 3; ++column) list->resizeColumnToContents(column); layout->addWidget(list); auto *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dialog); // Only worth offering for more than one file: with a single attachment it // is the same action as its own Save button, one dialog deeper. if (allAttachments().size() > 1) { auto *saveAll = buttons->addButton(tr("Save all..."), QDialogButtonBox::ActionRole); connect(saveAll, &QPushButton::clicked, this, [this, &dialog]() { saveAllAttachments(); dialog.accept(); }); } connect(buttons, &QDialogButtonBox::rejected, &dialog, &QDialog::reject); layout->addWidget(buttons); dialog.resize(560, 320); dialog.exec(); } void MessageView::saveAllAttachments() { const QList attachments = allAttachments(); if (attachments.isEmpty()) return; // The subfolder is stated up front rather than discovered afterwards: the // user picks a parent, and what lands in it is one directory, not fifteen // loose files among whatever is already there. const QString subject = m_items.isEmpty() ? QString() : m_items.first().message.subject; const QString rfc822Date = m_items.isEmpty() ? QString() : m_items.first().message.date; const QString folder = attachmentFolderName(rfc822Date, subject); const QString parent = QFileDialog::getExistingDirectory( this, tr("Choose a folder. A subfolder \"%1\" will be created inside it.") .arg(folder), QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); if (parent.isEmpty()) return; // cancelled // Never overwrite an existing directory: a second save of the same thread // gets its own folder rather than merging into the first. QDir parentDir(parent); QString unique = folder; for (int suffix = 2; parentDir.exists(unique); ++suffix) unique = tr("%1 (%2)").arg(folder).arg(suffix); if (!parentDir.mkpath(unique)) { emit statusMessage(tr("Could not create %1").arg(unique)); return; } const QString target = parentDir.absoluteFilePath(unique); int saved = 0; QStringList failures; for (const Attachment &attachment : attachments) { QString error; // Not saveTo(): several messages in a thread commonly attach the same // filename, and overwriting silently lost six of sixteen files while // still reporting every one as saved. if (attachment.saveWithoutOverwriting(target, &error).isEmpty()) failures.append(attachment.safeFilename()); else ++saved; } if (failures.isEmpty()) { emit statusMessage(tr("Saved %1 attachment(s) to %2") .arg(saved).arg(target)); } else { emit statusMessage(tr("Saved %1 of %2 to %3; failed: %4") .arg(saved).arg(attachments.size()) .arg(target, failures.join(QStringLiteral(", ")))); } } void MessageView::saveAttachment(const Attachment &attachment) { const QString directory = QFileDialog::getExistingDirectory( this, tr("Save attachment to"), QStandardPaths::writableLocation(QStandardPaths::DownloadLocation)); if (directory.isEmpty()) return; // cancelled QString error; const QString written = attachment.saveTo(directory, &error); if (written.isEmpty()) { emit statusMessage(tr("Could not save attachment: %1").arg(error)); return; } // Reported, not silent: a save with no feedback is the same failure as // acting on a thread and seeing nothing change. emit statusMessage(tr("Saved %1").arg(written)); } 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(); } bool MessageView::eventFilter(QObject *watched, QEvent *event) { const QEvent::Type type = event->type(); if (type != QEvent::Wheel && type != QEvent::MouseButtonPress) return QWidget::eventFilter(watched, event); // Application-wide filter: only events inside this pane are ours. Anything // else, including a Ctrl+wheel over the thread list, passes untouched. // isAncestorOf() is false for the widget itself, so test that separately. auto *widget = qobject_cast(watched); if (!widget || (widget != m_view && !m_view->isAncestorOf(widget))) return QWidget::eventFilter(watched, event); if (type == QEvent::Wheel) { auto *wheel = static_cast(event); if (!(wheel->modifiers() & Qt::ControlModifier)) return QWidget::eventFilter(watched, event); // angleDelta is in eighths of a degree; one detent is 120. A high // resolution wheel sends smaller steps, so scale rather than treating // every event as one full step. const int delta = wheel->angleDelta().y(); if (delta != 0) setZoomFactor(zoomFactor() + 0.1 * delta / 120.0); // Consumed, or Chromium's own Ctrl+wheel zoom would run on top of // ours and the factor we track would no longer be what is on screen. return true; } // Ctrl+middle-click resets: the same hand that just zoomed with the wheel // puts it back, without reaching for the keyboard. auto *mouse = static_cast(event); if (mouse->button() != Qt::MiddleButton || !(mouse->modifiers() & Qt::ControlModifier)) { return QWidget::eventFilter(watched, event); } zoomReset(); // Consumed: a plain middle click is paste-on-X11 in some contexts, and // this gesture must do one thing only. return true; } qreal MessageView::clampZoom(qreal factor) { // qIsFinite rejects the NaN and infinity a corrupt or hand-edited state // file can produce; qFuzzyIsNull rejects the 0.0 that a missing or // non-numeric value converts to, which would render nothing at all. if (!qIsFinite(factor) || factor <= 0.0) return kDefaultZoom; return qBound(kMinZoom, factor, kMaxZoom); } qreal MessageView::zoomFactor() const { // The web view is the single source of truth. It keeps the factor across // setHtml(), verified on Qt 6.11, so there is no second copy to drift. return m_view->zoomFactor(); } void MessageView::setZoomFactor(qreal factor) { m_view->setZoomFactor(clampZoom(factor)); } void MessageView::zoomIn() { setZoomFactor(zoomFactor() + 0.1); } void MessageView::zoomOut() { setZoomFactor(zoomFactor() - 0.1); } void MessageView::zoomReset() { setZoomFactor(kDefaultZoom); } 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(); }