Branch data Line data Source code
1 : : #include "MailView.h"
2 : :
3 : : #include <QFont>
4 : : #include <QFrame>
5 : : #include <QHBoxLayout>
6 : : #include <QLabel>
7 : : #include <QLoggingCategory>
8 : : #include <QMenu>
9 : : #include <QPixmap>
10 : : #include <QPainter>
11 : : #include <QPushButton>
12 : : #include <QSettings>
13 : : #include <QStackedWidget>
14 : : #include <QTextBrowser>
15 : :
16 : : #include <QVBoxLayout>
17 : : #include <QWebEngineProfile>
18 : : #include <QWebEngineSettings>
19 : : #include <QWebEngineView>
20 : :
21 : : #include "ui/AttachmentBar.h"
22 : : #include "ui/ExternalContentInterceptor.h"
23 : : #include "ui/LabelDelegate.h"
24 : : #include "ui/PdfViewerWidget.h"
25 : : #include "ui/ThemeManager.h"
26 : : #include "ui/UrlSchemeFilter.h"
27 : : #include "data/MailCache.h"
28 : :
29 : : #include "service/ImapResponseParser.h"
30 : : #include <QApplication>
31 : : #include <QClipboard>
32 : : #include <QContextMenuEvent>
33 : : #include <QDesktopServices>
34 : : #include <QEvent>
35 : : #include <QRegularExpression>
36 : : #include <QWebEngineContextMenuRequest>
37 : : #include <QWebEngineNewWindowRequest>
38 : :
39 : : #include <algorithm>
40 : :
41 [ + + + - : 15 : Q_LOGGING_CATEGORY(lcMailView, "mailjd.mailview")
+ - - - ]
42 : :
43 : : // T-351: Override contextMenuEvent to suppress Chromium's default context menu.
44 : 0 : void MailWebEngineView::contextMenuEvent(QContextMenuEvent *event) {
45 : 0 : emit mailContextMenuRequested(event->globalPos());
46 : 0 : event->accept();
47 : 0 : }
48 : :
49 [ + - ]: 90 : MailView::MailView(QWidget *parent) : QWidget(parent) {
50 [ + - + - : 90 : m_mainLayout = new QVBoxLayout(this);
- + - - ]
51 [ + - ]: 90 : m_mainLayout->setContentsMargins(0, 0, 0, 0);
52 [ + - ]: 90 : m_mainLayout->setSpacing(0);
53 : :
54 : : // --- Header container (subject + meta with background) ---
55 [ + - + - : 90 : m_headerFrame = new QFrame(this);
- + - - ]
56 [ + - ]: 180 : m_headerFrame->setObjectName(QStringLiteral("mailHeader"));
57 [ + - ]: 90 : m_headerFrame->setFrameShape(QFrame::NoFrame);
58 [ + - ]: 90 : m_headerFrame->setVisible(false);
59 : :
60 [ + - + - : 90 : auto *headerLayout = new QVBoxLayout(m_headerFrame);
- + - - ]
61 [ + - ]: 90 : headerLayout->setContentsMargins(0, 0, 0, 0);
62 [ + - ]: 90 : headerLayout->setSpacing(0);
63 : :
64 : : // 67.B4: sender avatar (initials on a deterministic color disc) to the
65 : : // left of subject + meta
66 [ + - + - : 90 : auto *headerTopLayout = new QHBoxLayout();
- + - - ]
67 [ + - ]: 90 : headerTopLayout->setContentsMargins(12, 12, 12, 0);
68 [ + - ]: 90 : headerTopLayout->setSpacing(12);
69 : :
70 [ + - + - : 90 : m_avatarLabel = new QLabel(m_headerFrame);
- + - - ]
71 [ + - ]: 180 : m_avatarLabel->setObjectName(QStringLiteral("senderAvatar"));
72 [ + - ]: 90 : m_avatarLabel->setFixedSize(40, 40);
73 [ + - ]: 90 : m_avatarLabel->setAlignment(Qt::AlignCenter);
74 [ + - ]: 90 : headerTopLayout->addWidget(m_avatarLabel, 0, Qt::AlignTop);
75 : :
76 [ + - + - : 90 : auto *headerTextLayout = new QVBoxLayout();
- + - - ]
77 [ + - ]: 90 : headerTextLayout->setContentsMargins(0, 0, 0, 0);
78 [ + - ]: 90 : headerTextLayout->setSpacing(0);
79 : :
80 : : // Subject label (bold heading)
81 [ + - + - : 90 : m_subjectLabel = new QLabel(m_headerFrame);
- + - - ]
82 [ + - ]: 180 : m_subjectLabel->setObjectName(QStringLiteral("subjectLabel"));
83 [ + - ]: 90 : m_subjectLabel->setWordWrap(true);
84 : : // T-71.4: allow mouse text selection (copy subject). Mouse-only — no
85 : : // keyboard/link side effects (would need TextBrowserInteraction).
86 [ + - ]: 90 : m_subjectLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
87 : : // TextSelectableByMouse implicitly adds Qt::ClickFocus, which steals focus
88 : : // from the mail list. That lets single-key shortcuts (e.g. 'n' = compose)
89 : : // fire while the user is selecting header text. Force NoFocus so key events
90 : : // stay routed to whoever had focus before the mouse click.
91 [ + - ]: 90 : m_subjectLabel->setFocusPolicy(Qt::NoFocus);
92 : :
93 : : // Also set font programmatically (Breeze can override stylesheet font)
94 [ + - ]: 90 : QFont subjectFont;
95 [ + - ]: 90 : subjectFont.setPointSize(14);
96 [ + - ]: 90 : subjectFont.setBold(true);
97 [ + - ]: 90 : m_subjectLabel->setFont(subjectFont);
98 [ + - ]: 90 : headerTextLayout->addWidget(m_subjectLabel);
99 : :
100 : : // Meta label (Von/An/Datum on separate lines)
101 [ + - + - : 90 : m_metaLabel = new QLabel(m_headerFrame);
- + - - ]
102 [ + - ]: 180 : m_metaLabel->setObjectName(QStringLiteral("metaLabel"));
103 [ + - ]: 90 : m_metaLabel->setWordWrap(true);
104 : : // T-71.4: allow mouse text selection (copy from/to/date). Mouse-only.
105 [ + - ]: 90 : m_metaLabel->setTextInteractionFlags(Qt::TextSelectableByMouse);
106 [ + - ]: 90 : m_metaLabel->setFocusPolicy(Qt::NoFocus); // see subjectLabel comment
107 [ + - ]: 90 : headerTextLayout->addWidget(m_metaLabel);
108 : :
109 [ + - ]: 90 : headerTopLayout->addLayout(headerTextLayout, 1);
110 [ + - ]: 90 : headerLayout->addLayout(headerTopLayout);
111 : :
112 : : // Labels row: horizontal layout for colored chip widgets
113 [ + - + - : 90 : m_labelsLayout = new QHBoxLayout();
- + - - ]
114 [ + - ]: 90 : m_labelsLayout->setContentsMargins(8, 0, 8, 6);
115 [ + - ]: 90 : m_labelsLayout->setSpacing(4);
116 [ + - ]: 90 : m_labelsLayout->addStretch();
117 [ + - ]: 90 : headerLayout->addLayout(m_labelsLayout);
118 : :
119 [ + - ]: 90 : m_mainLayout->addWidget(m_headerFrame);
120 : :
121 : : // --- Attachment bar ---
122 [ + - + - : 90 : m_attachmentBar = new AttachmentBar(this);
- + - - ]
123 [ + - ]: 90 : m_mainLayout->addWidget(m_attachmentBar);
124 : :
125 : : // --- Info bar (toolbar for view mode + external content) ---
126 [ + - + - : 90 : m_infoBar = new QFrame(this);
- + - - ]
127 [ + - ]: 180 : m_infoBar->setObjectName(QStringLiteral("infoBar"));
128 [ + - ]: 90 : m_infoBar->setFrameShape(QFrame::NoFrame);
129 : : // Sprint 64: Handled by global ThemeManager (main.qss).
130 : :
131 [ + - + - : 90 : auto *infoLayout = new QHBoxLayout(m_infoBar);
- + - - ]
132 [ + - ]: 90 : infoLayout->setContentsMargins(6, 2, 6, 2);
133 : :
134 [ + - + - : 90 : m_infoLabel = new QLabel(m_infoBar);
- + - - ]
135 [ + - + - ]: 90 : m_infoLabel->setText(tr("⚠ External content has been blocked"));
136 [ + - ]: 90 : m_infoLabel->setVisible(false);
137 [ + - ]: 90 : infoLayout->addWidget(m_infoLabel);
138 : :
139 : : // T-202: Use manual popup instead of setMenu() to avoid misaligned native arrow
140 [ + - + - : 90 : m_loadExternalBtn = new QPushButton(tr("Settings \u25BE"), m_infoBar);
+ - - + -
- ]
141 [ + - ]: 90 : m_loadExternalBtn->setFlat(true);
142 [ + - ]: 90 : m_loadExternalBtn->setVisible(false);
143 [ + - ]: 90 : connect(m_loadExternalBtn, &QPushButton::clicked, this, [this]() {
144 [ + - ]: 1 : if (m_externalMenu) {
145 [ + - ]: 1 : m_externalMenu->popup(
146 [ + - ]: 1 : m_loadExternalBtn->mapToGlobal(
147 : 2 : QPoint(0, m_loadExternalBtn->height())));
148 : : }
149 : 1 : });
150 [ + - ]: 90 : infoLayout->addWidget(m_loadExternalBtn);
151 : :
152 [ + - ]: 90 : infoLayout->addStretch();
153 : :
154 [ + - + - : 180 : m_toggleBtn = new QPushButton(QStringLiteral("HTML"), m_infoBar);
- + - - ]
155 [ + - ]: 90 : m_toggleBtn->setCheckable(true);
156 [ + - + - ]: 90 : m_toggleBtn->setToolTip(tr("Toggle Text/HTML (h)"));
157 [ + - ]: 90 : connect(m_toggleBtn, &QPushButton::clicked, this, &MailView::toggleViewMode);
158 [ + - ]: 90 : infoLayout->addWidget(m_toggleBtn);
159 : :
160 [ + - + - : 180 : m_sourceBtn = new QPushButton(QStringLiteral("Source"), m_infoBar);
- + - - ]
161 [ + - ]: 90 : m_sourceBtn->setCheckable(true);
162 [ + - + - ]: 90 : m_sourceBtn->setToolTip(tr("Show Source"));
163 [ + - ]: 90 : connect(m_sourceBtn, &QPushButton::clicked, this, &MailView::showSource);
164 [ + - ]: 90 : infoLayout->addWidget(m_sourceBtn);
165 : :
166 [ + - ]: 90 : m_infoBar->setVisible(false);
167 [ + - ]: 90 : m_mainLayout->addWidget(m_infoBar);
168 : :
169 : : // --- Content stack ---
170 [ + - + - : 90 : m_stack = new QStackedWidget(this);
- + - - ]
171 : :
172 : : // Page 0: QTextBrowser for plain text
173 [ + - + - : 90 : m_textBrowser = new QTextBrowser(this);
- + - - ]
174 [ + - ]: 90 : m_textBrowser->setOpenLinks(false);
175 [ + - ]: 90 : m_textBrowser->setOpenExternalLinks(false);
176 [ + - ]: 90 : m_textBrowser->setFrameShape(QFrame::NoFrame);
177 : : // T-353: Handle link clicks with explicit scheme validation
178 : 90 : connect(m_textBrowser, &QTextBrowser::anchorClicked,
179 [ + - ]: 90 : this, [this](const QUrl &url) {
180 [ + - + - ]: 2 : QString scheme = url.scheme().toLower();
181 [ + - + + ]: 2 : if (isAllowedExternalScheme(scheme)) {
182 [ + - + - : 2 : qCInfo(lcMailView) << "Opening link from plain-text viewer:"
+ - + + ]
183 [ + - + - ]: 1 : << url.toString();
184 [ + - ]: 1 : QDesktopServices::openUrl(url);
185 : : } else {
186 [ + - + - : 2 : qCWarning(lcMailView) << "Blocked link with disallowed scheme:"
+ - + + ]
187 [ + - + - : 1 : << scheme << "URL:" << url.toString();
+ - + - ]
188 : : }
189 : 2 : });
190 : : // T-351/Sprint 75: Custom context menu for plain-text viewer. The
191 : : // signal delivers viewport-local coordinates; the named handler maps
192 : : // them to global screen coordinates so showMailContextMenu() works
193 : : // with global coordinates from BOTH callers (WebEngine already emits
194 : : // event->globalPos()).
195 [ + - ]: 90 : m_textBrowser->setContextMenuPolicy(Qt::CustomContextMenu);
196 : 90 : connect(m_textBrowser, &QWidget::customContextMenuRequested, this,
197 [ + - ]: 90 : &MailView::onPlainTextContextMenuRequested);
198 [ + - ]: 90 : m_stack->addWidget(m_textBrowser);
199 : :
200 : : // Page 1: QWebEngineView — eager init so the GL mode switch (which causes
201 : : // X11 window unmap/remap) happens during construction, BEFORE window.show().
202 : : // setHtml("") is required to actually trigger the GL context switch.
203 : : // Skip in unit tests (MAILJD_SKIP_WEBENGINE=1) — WebEngine init blocks
204 : : // for minutes on CI without GPU/display.
205 [ + + ]: 90 : if (!qEnvironmentVariableIsSet("MAILJD_SKIP_WEBENGINE")) {
206 [ + - ]: 10 : ensureWebEngine();
207 [ + - ]: 10 : if (m_webView)
208 [ + - + - ]: 10 : m_webView->setHtml(QString());
209 : : }
210 : :
211 : : // T-71.6: Page 2 — inline PDF viewer. Created eagerly (cheap until load()
212 : : // is called). Connected INSIDE MailView (not MainWindow) so the inline
213 : : // path also works for MailView instances owned by MailTabWidget — the
214 : : // previous wiring only connected AttachmentBar signals on the main view.
215 [ + - + - : 90 : m_pdfView = new PdfViewerWidget(this);
- + - - ]
216 [ + - ]: 90 : m_stack->addWidget(m_pdfView);
217 : 90 : connect(m_attachmentBar, &AttachmentBar::viewInlineRequested, this,
218 [ + - ]: 90 : &MailView::showAttachmentInline);
219 : 90 : connect(m_pdfView, &PdfViewerWidget::backRequested, this,
220 [ + - ]: 90 : &MailView::leavePdfViewer);
221 : :
222 [ + - ]: 90 : m_mainLayout->addWidget(m_stack, 1);
223 : 90 : }
224 : :
225 : 76 : void MailView::displayMail(const MailHeader &header, const MailBody &body) {
226 : 76 : m_currentHeader = header;
227 : 76 : m_currentBody = body;
228 : 76 : m_externalContentOverride = false; // T-073: reset per-mail override
229 : :
230 : : // T-122: Set up interceptor whitelist if cache is available
231 [ + + + + ]: 76 : if (m_interceptor && m_cache) {
232 [ + - ]: 45 : m_interceptor->setWhitelist(
233 [ + - ]: 90 : m_cache->whitelistedDomains(),
234 [ + - ]: 90 : m_cache->whitelistedSenders());
235 [ + - ]: 45 : m_interceptor->resetBlockedDomains();
236 : : }
237 : :
238 : : // 67.B4: avatar = sender initials on a deterministic color disc
239 [ + - + - ]: 76 : m_avatarLabel->setPixmap(renderAvatar(header.from));
240 : :
241 : : // Set native header labels
242 : : // T-606/SEC-05: Force PlainText to prevent tracking pixel injection via
243 : : // HTML-formatted subjects (e.g. <img src="https://tracker/pixel.png">)
244 [ + - ]: 76 : m_subjectLabel->setTextFormat(Qt::PlainText);
245 [ + - ]: 152 : m_subjectLabel->setText(header.subject.isEmpty()
246 [ + + + - ]: 152 : ? tr("(No Subject)")
247 : 72 : : header.subject);
248 [ + - ]: 76 : m_headerFrame->setVisible(true);
249 : :
250 : : // Meta as plain text (no HTML needed now)
251 : : QString meta =
252 [ + - ]: 76 : tr("From: %1\nTo: %2\nDate: %3")
253 : 76 : .arg(header.from, header.to,
254 [ + - + - ]: 152 : header.date.toString(QStringLiteral("dd. MMM yyyy, HH:mm")));
255 [ + - ]: 76 : m_metaLabel->setTextFormat(Qt::PlainText);
256 [ + - ]: 76 : m_metaLabel->setText(meta);
257 : :
258 : : // Render label chips
259 [ + - ]: 76 : refreshLabels(header.labels);
260 : :
261 : : // Show attachments
262 [ + - ]: 76 : m_attachmentBar->setAttachments(body.attachments);
263 : :
264 : : // Determine view mode from settings
265 [ + - ]: 76 : QSettings settings;
266 : : QString defaultMode =
267 [ + - ]: 228 : settings.value(QStringLiteral("view/defaultMode"), QStringLiteral("text"))
268 [ + - ]: 76 : .toString();
269 : :
270 : 76 : m_showHtml =
271 [ + + + - : 152 : (defaultMode == QStringLiteral("html") && !body.textHtml.isEmpty());
+ - + - ]
272 : :
273 [ + + + + : 76 : if (body.textPlain.isEmpty() && !body.textHtml.isEmpty()) {
+ + ]
274 : 10 : m_showHtml = true;
275 : : }
276 : :
277 [ + - ]: 76 : m_toggleBtn->setChecked(m_showHtml);
278 [ + + + - ]: 93 : m_toggleBtn->setVisible(!body.textHtml.isEmpty() &&
279 [ + + ]: 17 : !body.textPlain.isEmpty());
280 [ + - ]: 76 : m_sourceBtn->setChecked(false);
281 [ + - ]: 76 : m_sourceBtn->setVisible(!body.rawSource.isEmpty());
282 : :
283 [ + - ]: 76 : m_infoBar->setVisible(true);
284 : :
285 [ + - ]: 76 : renderCurrentBody();
286 : 76 : }
287 : :
288 : 153 : void MailView::refreshLabels(const QStringList &labels) {
289 : : // Clear previous label chips (keep the stretch at the end)
290 [ + + ]: 158 : while (m_labelsLayout->count() > 1) {
291 : 5 : auto *item = m_labelsLayout->takeAt(0);
292 [ + - ]: 5 : if (item->widget()) {
293 [ + - ]: 5 : delete item->widget();
294 : : }
295 [ + - ]: 5 : delete item;
296 : : }
297 : :
298 : : // Add label chips as real QLabel widgets (supports border-radius)
299 [ + + ]: 163 : for (const auto &label : labels) {
300 [ + - + + ]: 10 : if (ImapResponseParser::isInternalKeyword(label))
301 : 2 : continue;
302 [ + - ]: 8 : QString name = LabelDelegate::displayName(label);
303 [ + - ]: 8 : QColor color = LabelDelegate::colorForLabel(label);
304 : :
305 [ + - + - : 8 : auto *chip = new QLabel(name, m_headerFrame);
- + - - ]
306 [ + - ]: 16 : chip->setObjectName(QStringLiteral("mailLabelChip"));
307 [ + - ]: 8 : chip->setTextFormat(Qt::PlainText);
308 : : // Sprint 69: only the dynamic per-label background/foreground stays
309 : : // inline; geometry comes from main.qss. Foreground is contrast-computed
310 : : // against the label color so it stays readable without a hex literal
311 : : // (Qt color enums → .name() at runtime keeps the gate green).
312 [ - + ]: 8 : QColor chipFg = color.lightness() > 150 ? Qt::black : Qt::white;
313 [ + - ]: 16 : chip->setStyleSheet(
314 : 16 : QStringLiteral("background-color: %1; color: %2;")
315 [ + - + - : 16 : .arg(color.name(), chipFg.name()));
+ - ]
316 [ + - ]: 8 : chip->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Fixed);
317 [ + - + - ]: 8 : m_labelsLayout->insertWidget(m_labelsLayout->count() - 1, chip);
318 : 8 : }
319 : 153 : }
320 : :
321 : 34 : void MailView::clear() {
322 : 34 : ++m_renderGeneration;
323 : 34 : m_pendingHtmlGeneration = 0;
324 : 34 : m_textBrowser->clear();
325 : 34 : m_stack->setCurrentWidget(m_textBrowser);
326 [ + + ]: 34 : if (m_webView) {
327 [ + - + - ]: 22 : m_webView->setHtml(QString());
328 : : }
329 : 34 : m_attachmentBar->clear();
330 : 34 : m_infoBar->setVisible(false);
331 : 34 : m_headerFrame->setVisible(false);
332 : 34 : m_currentBody = {};
333 : 34 : m_currentHeader = {};
334 : 68 : }
335 : :
336 : 2 : void MailView::toggleViewMode() {
337 [ + - - + : 2 : if (m_currentBody.textHtml.isEmpty() || m_currentBody.textPlain.isEmpty()) {
- + ]
338 : 0 : return;
339 : : }
340 : :
341 : 2 : m_showHtml = !m_showHtml;
342 : 2 : m_toggleBtn->setChecked(m_showHtml);
343 : 2 : m_sourceBtn->setChecked(false);
344 : 2 : renderCurrentBody();
345 : : }
346 : :
347 : 5 : void MailView::showSource() {
348 [ + + ]: 5 : if (m_currentBody.rawSource.isEmpty()) {
349 : 1 : return;
350 : : }
351 : :
352 : 4 : bool showingSrc = m_sourceBtn->isChecked();
353 [ + + ]: 4 : if (showingSrc) {
354 : 2 : ++m_renderGeneration;
355 : 2 : m_pendingHtmlGeneration = 0;
356 [ + + ]: 2 : if (m_webView)
357 : 1 : m_webView->stop();
358 [ + - + - ]: 2 : m_textBrowser->setPlainText(QString::fromUtf8(m_currentBody.rawSource));
359 [ + - + - ]: 4 : m_textBrowser->setFont(QFont(QStringLiteral("monospace"), 9));
360 : 2 : m_stack->setCurrentIndex(0);
361 : : } else {
362 : 2 : renderCurrentBody();
363 : : }
364 : : }
365 : :
366 : 3 : void MailView::loadExternalContent() {
367 [ + + ]: 3 : if (m_interceptor) {
368 : 2 : m_externalContentOverride = true; // T-073: override for this mail
369 : 2 : m_interceptor->setBlockExternal(false);
370 : 2 : m_infoLabel->setVisible(false);
371 : 2 : m_loadExternalBtn->setVisible(false);
372 : 2 : renderCurrentBody();
373 : 2 : emit externalContentLoadRequested();
374 : : }
375 : 3 : }
376 : :
377 : 25 : void MailView::ensureWebEngine() {
378 [ + + ]: 25 : if (m_webView)
379 : 15 : return;
380 : :
381 [ + - + - : 20 : qCInfo(lcMailView) << "Initializing QWebEngineView (lazy init)";
+ - + + ]
382 : :
383 : : // T-352: Use off-the-record profile (no disk cache, no cookies)
384 : : // A default-constructed QWebEngineProfile is off-the-record (in-memory only).
385 [ + - - + : 10 : m_webProfile = new QWebEngineProfile(this);
- - ]
386 : :
387 : : // T-608/SEC-04: Initialize DOMPurify-based HTML sanitizer
388 [ + - - + : 10 : m_htmlSanitizer = new HtmlSanitizer(this);
- - ]
389 : 10 : m_htmlSanitizer->init();
390 : :
391 [ + - - + : 10 : m_interceptor = new ExternalContentInterceptor(this);
- - ]
392 : 10 : m_webProfile->setUrlRequestInterceptor(m_interceptor);
393 : :
394 : 10 : connect(m_interceptor, &ExternalContentInterceptor::externalContentBlocked,
395 [ + - ]: 10 : this, [this]() {
396 [ + - ]: 3 : QSettings settings;
397 : : QString pref = settings
398 [ + - ]: 9 : .value(QStringLiteral("view/externalContent"),
399 : 6 : QStringLiteral("block"))
400 [ + - ]: 3 : .toString();
401 [ - + ]: 3 : if (pref == QStringLiteral("load")) {
402 [ # # ]: 0 : m_interceptor->setBlockExternal(false);
403 [ # # ]: 0 : renderCurrentBody();
404 : : } else {
405 [ + - ]: 3 : m_infoLabel->setVisible(true);
406 [ + - ]: 3 : m_loadExternalBtn->setVisible(true);
407 : : // T-122: Build dropdown menu with whitelist actions
408 [ + - ]: 3 : buildExternalContentMenu();
409 : : }
410 : 3 : });
411 : :
412 : : // T-350: Link click handling — done via ExternalContentInterceptor
413 : : // (NavigationTypeLink detection). No setPage() needed, which avoids
414 : : // destroying the pre-warmed Chromium renderer from the constructor.
415 [ + - ]: 10 : connect(m_interceptor, &ExternalContentInterceptor::linkClicked,
416 : 0 : this, [](const QUrl &url) {
417 [ # # # # ]: 0 : QString scheme = url.scheme().toLower();
418 [ # # # # ]: 0 : if (isAllowedExternalScheme(scheme)) {
419 [ # # # # : 0 : qCInfo(lcMailView) << "Opening link in browser:" << url.toString();
# # # # #
# # # ]
420 [ # # ]: 0 : QDesktopServices::openUrl(url);
421 : : } else {
422 [ # # # # : 0 : qCDebug(lcMailView) << "Blocked link with scheme:" << scheme;
# # # # #
# ]
423 : : }
424 : 0 : });
425 : :
426 [ + - - + : 10 : m_webView = new MailWebEngineView(m_webProfile, this);
- - ]
427 : :
428 : 10 : connect(m_webView, &QWebEngineView::loadFinished, this,
429 [ + - ]: 10 : &MailView::finishHtmlLoad);
430 : :
431 : : // T-351: Connect context menu from our view subclass
432 : 10 : connect(m_webView, &MailWebEngineView::mailContextMenuRequested,
433 [ + - ]: 10 : this, &MailView::showMailContextMenu);
434 : :
435 : : // T-350: Handle target="_blank" links via newWindowRequested signal.
436 : : // The default QWebEnginePage::createWindow() returns nullptr (no new window).
437 [ + - + - ]: 10 : connect(m_webView->page(), &QWebEnginePage::newWindowRequested,
438 : 0 : this, [](QWebEngineNewWindowRequest &request) {
439 [ # # ]: 0 : QUrl url = request.requestedUrl();
440 [ # # # # : 0 : if (isAllowedExternalScheme(url.scheme().toLower())) {
# # # # ]
441 [ # # # # : 0 : qCInfo(lcMailView) << "target=_blank link:" << url.toString();
# # # # #
# # # ]
442 [ # # ]: 0 : QDesktopServices::openUrl(url);
443 : : }
444 : 0 : });
445 : :
446 : : // T-352: Comprehensive WebEngine security hardening
447 : 10 : auto *settings = m_webView->page()->settings();
448 : :
449 : : // Core security (existing)
450 : 10 : settings->setAttribute(QWebEngineSettings::JavascriptEnabled, false);
451 : 10 : settings->setAttribute(QWebEngineSettings::PluginsEnabled, false);
452 : 10 : settings->setAttribute(QWebEngineSettings::LocalContentCanAccessRemoteUrls, false);
453 : :
454 : : // Tracking & fingerprinting prevention
455 : 10 : settings->setAttribute(QWebEngineSettings::LocalStorageEnabled, false);
456 : 10 : settings->setAttribute(QWebEngineSettings::WebGLEnabled, false);
457 : 10 : settings->setAttribute(QWebEngineSettings::Accelerated2dCanvasEnabled, false);
458 : :
459 : : // Disable unnecessary features
460 : 10 : settings->setAttribute(QWebEngineSettings::AutoLoadIconsForPage, false);
461 : 10 : settings->setAttribute(QWebEngineSettings::ErrorPageEnabled, false);
462 : 10 : settings->setAttribute(QWebEngineSettings::ScreenCaptureEnabled, false);
463 : 10 : settings->setAttribute(QWebEngineSettings::NavigateOnDropEnabled, false);
464 : :
465 : : // Local content isolation
466 : 10 : settings->setAttribute(QWebEngineSettings::LocalContentCanAccessFileUrls, false);
467 : :
468 : 10 : m_stack->addWidget(m_webView);
469 : : }
470 : :
471 : 0 : void MailView::showAttachmentInline(qint64 attachmentId) {
472 : : // T-71.6: locate the attachment metadata in the currently displayed mail.
473 : 0 : const auto &atts = m_currentBody.attachments;
474 [ # # ]: 0 : auto it = std::find_if(
475 : : atts.begin(), atts.end(),
476 : 0 : [attachmentId](const Attachment &a) { return a.id == attachmentId; });
477 [ # # ]: 0 : if (it == atts.end()) {
478 [ # # # # : 0 : qCWarning(lcMailView) << "showAttachmentInline: attachment id not found:"
# # # # ]
479 [ # # ]: 0 : << attachmentId;
480 : 0 : return;
481 : : }
482 : :
483 : : // Only render PDFs inline (the AttachmentBar only emits viewInlineRequested
484 : : // for PDFs, but double-check here as a defense-in-depth).
485 : : const bool isPdf =
486 [ # # # # : 0 : it->contentType.startsWith(QStringLiteral("application/pdf"),
# # ]
487 [ # # # # ]: 0 : Qt::CaseInsensitive) ||
488 [ # # # # : 0 : it->filename.endsWith(QStringLiteral(".pdf"), Qt::CaseInsensitive);
# # # # #
# # # #
# ]
489 [ # # ]: 0 : if (!isPdf) {
490 [ # # # # : 0 : qCDebug(lcMailView) << "showAttachmentInline: not a PDF, ignoring"
# # # # ]
491 [ # # ]: 0 : << it->filename;
492 : 0 : return;
493 : : }
494 : :
495 : : // The Attachment struct has no data BLOB (Models.h:82-88) — load lazily.
496 [ # # ]: 0 : if (!m_cache) {
497 [ # # # # : 0 : qCWarning(lcMailView) << "showAttachmentInline: no MailCache wired";
# # # # ]
498 : 0 : return;
499 : : }
500 [ # # ]: 0 : const QByteArray data = m_cache->attachmentData(attachmentId);
501 [ # # ]: 0 : if (data.isEmpty()) {
502 [ # # # # : 0 : qCWarning(lcMailView) << "showAttachmentInline: empty BLOB for id"
# # # # ]
503 [ # # ]: 0 : << attachmentId;
504 : 0 : return;
505 : : }
506 : :
507 : 0 : ++m_renderGeneration;
508 : 0 : m_pendingHtmlGeneration = 0;
509 [ # # ]: 0 : if (m_webView)
510 [ # # ]: 0 : m_webView->stop();
511 [ # # ]: 0 : m_previousStackIndex = m_stack->currentIndex();
512 [ # # ]: 0 : m_pdfView->load(data, it->filename);
513 [ # # ]: 0 : m_stack->setCurrentWidget(m_pdfView);
514 [ # # # # : 0 : qCInfo(lcMailView) << "Opening PDF inline:" << it->filename
# # # # #
# ]
515 [ # # # # : 0 : << "(" << data.size() << "bytes)";
# # ]
516 [ # # ]: 0 : }
517 : :
518 : 0 : void MailView::leavePdfViewer() {
519 : 0 : m_stack->setCurrentIndex(m_previousStackIndex);
520 : 0 : m_pdfView->clear();
521 : 0 : }
522 : :
523 : 82 : void MailView::renderCurrentBody() {
524 : 82 : const quint64 generation = ++m_renderGeneration;
525 [ + + + - : 82 : if (m_showHtml && !m_currentBody.textHtml.isEmpty()) {
+ + ]
526 [ + - ]: 15 : ensureWebEngine();
527 : :
528 [ + - ]: 15 : applyExternalContentPolicy();
529 : :
530 : : // T-546: CSP must allow external img-src so ExternalContentInterceptor
531 : : // can intercept requests and emit externalContentBlocked() to show the
532 : : // info bar. If CSP blocks first (img-src data: cid:), the interceptor
533 : : // never fires and the "Settings" dropdown never appears.
534 : 15 : QString csp = QStringLiteral(
535 : : "default-src 'none'; style-src 'unsafe-inline'; "
536 : : "img-src data: cid: https: http:;");
537 : :
538 : : // setHtml() is asynchronous and does not synchronously replace the old
539 : : // rendered document. Move to a non-WebEngine placeholder first and keep it
540 : : // visible until the sanitized replacement reports loadFinished.
541 [ + - ]: 15 : showHtmlSanitizingPlaceholder();
542 : :
543 : 15 : m_htmlSanitizer->sanitize(
544 [ + - ]: 15 : m_currentBody.textHtml,
545 [ + - - - ]: 30 : [this, csp, generation](const QString &sanitizedHtml) {
546 : 14 : applySanitizedHtml(sanitizedHtml, csp, generation);
547 : 14 : });
548 : :
549 [ + - ]: 15 : m_infoLabel->setVisible(false);
550 [ + - ]: 15 : m_loadExternalBtn->setVisible(false);
551 : :
552 : 15 : } else {
553 : 67 : m_pendingHtmlGeneration = 0;
554 : 67 : QString plainText = m_currentBody.textPlain;
555 [ + - ]: 67 : plainText.remove('\r');
556 [ + - ]: 67 : QString bodyText = plainText.toHtmlEscaped();
557 : :
558 : : // T-162: Linkify URLs in plain text mails
559 : : // 67.B3: link color comes from the theme (@link token)
560 : : const QString linkColor =
561 [ + - + - ]: 134 : ThemeManager::instance().color(QStringLiteral("@link"));
562 : : // HTTP/HTTPS URLs
563 : : static QRegularExpression urlRegex(
564 : 16 : QStringLiteral(R"((https?://[^\s<>&"'\)]+))"),
565 [ + + + - : 83 : QRegularExpression::CaseInsensitiveOption);
+ - - - ]
566 [ + - ]: 67 : bodyText.replace(urlRegex,
567 : 134 : QStringLiteral(R"(<a href="\1" style="color: %1;">\1</a>)")
568 [ + - ]: 134 : .arg(linkColor));
569 : :
570 : : // Email addresses (mailto links)
571 : : static QRegularExpression mailtoRegex(
572 [ + + + - : 75 : QStringLiteral(R"(([\w.+-]+@[\w.-]+\.[a-zA-Z]{2,}))"));
+ - - - ]
573 [ + - ]: 67 : bodyText.replace(mailtoRegex,
574 : 134 : QStringLiteral(R"(<a href="mailto:\1" style="color: %1;">\1</a>)")
575 [ + - ]: 134 : .arg(linkColor));
576 : :
577 [ + - + - ]: 67 : m_textBrowser->setFont(QFont());
578 [ + - ]: 134 : m_textBrowser->setHtml(
579 : 134 : QStringLiteral("<pre style='font-family: monospace; padding: 8px; "
580 : : "white-space: pre-wrap;'>%1</pre>")
581 [ + - ]: 134 : .arg(bodyText));
582 [ + - ]: 67 : m_stack->setCurrentIndex(0);
583 [ + - ]: 67 : m_infoLabel->setVisible(false);
584 [ + - ]: 67 : m_loadExternalBtn->setVisible(false);
585 : 67 : }
586 : 82 : }
587 : :
588 : 16 : void MailView::showHtmlSanitizingPlaceholder() {
589 : 16 : m_pendingHtmlGeneration = 0;
590 [ + + ]: 16 : if (m_webView)
591 : 15 : m_webView->stop();
592 [ + - + - ]: 16 : m_textBrowser->setPlainText(tr("Preparing message..."));
593 : 16 : m_stack->setCurrentWidget(m_textBrowser);
594 : 16 : }
595 : :
596 : 16 : void MailView::applyExternalContentPolicy() {
597 [ + - + + ]: 16 : if (!m_interceptor || m_externalContentOverride)
598 : 3 : return;
599 : :
600 [ + - ]: 13 : QSettings settings;
601 : : const QString preference =
602 : : settings
603 [ + - ]: 39 : .value(QStringLiteral("view/externalContent"),
604 : 26 : QStringLiteral("block"))
605 [ + - ]: 13 : .toString();
606 : :
607 : : // SEC-2026-06-27-03: never derive trust from the visible From header.
608 : : // Sender whitelist rows remain readable for settings compatibility, but
609 : : // only an explicit per-message action, the global opt-in, or the requested
610 : : // resource's own allowlisted origin may load remote content.
611 [ + - ]: 26 : m_interceptor->setBlockExternal(preference != QStringLiteral("load"));
612 : 13 : }
613 : :
614 : 15 : bool MailView::applySanitizedHtml(const QString &sanitizedHtml,
615 : : const QString &csp,
616 : : quint64 generation) {
617 [ + + - + ]: 15 : if (generation != m_renderGeneration || !m_webView)
618 : 1 : return false;
619 : :
620 : : // 67.B3: link color comes from the theme (@link token)
621 : : const QString linkColor =
622 [ + - + - ]: 28 : ThemeManager::instance().color(QStringLiteral("@link"));
623 : 28 : QString fullHtml = QStringLiteral(
624 : : "<!DOCTYPE html><html><head>"
625 : : "<meta charset='utf-8'>"
626 : : "<meta http-equiv='Content-Security-Policy' content=\"%1\">"
627 : : "<style>body { font-family: sans-serif; margin: 8px; } "
628 : : "a[href] { cursor: pointer; color: %2; }</style>"
629 : : "</head><body>")
630 [ + - ]: 14 : .arg(csp, linkColor);
631 [ + - ]: 14 : fullHtml += sanitizedHtml;
632 [ + - ]: 14 : fullHtml += QStringLiteral("</body></html>");
633 : 14 : m_pendingHtmlGeneration = generation;
634 [ + - + - ]: 14 : m_webView->setHtml(fullHtml);
635 : 14 : return true;
636 : 14 : }
637 : :
638 : 40 : void MailView::finishHtmlLoad(bool ok) {
639 [ + + ]: 40 : if (m_pendingHtmlGeneration == 0 ||
640 [ + - - + ]: 15 : m_pendingHtmlGeneration != m_renderGeneration || !m_showHtml) {
641 : 25 : return;
642 : : }
643 : 15 : m_pendingHtmlGeneration = 0;
644 [ + + ]: 15 : if (!ok) {
645 : 1 : const QString fallback = m_currentBody.textPlain.isEmpty()
646 [ - + ]: 1 : ? tr("Unable to display this HTML message.")
647 [ - - ]: 1 : : m_currentBody.textPlain;
648 [ + - ]: 1 : m_textBrowser->setPlainText(fallback);
649 [ + - ]: 1 : m_stack->setCurrentWidget(m_textBrowser);
650 : 1 : return;
651 : 1 : }
652 : 14 : m_stack->setCurrentWidget(m_webView);
653 : : }
654 : :
655 : : // T-122: Inject MailCache for whitelist access
656 : 66 : void MailView::setCache(MailCache *cache) {
657 : 66 : m_cache = cache;
658 : 66 : }
659 : :
660 : : // T-544: Reload whitelist from MailCache into ExternalContentInterceptor
661 : : // Called after remote settings sync updates the whitelist in MailCache.
662 : 4 : void MailView::reloadWhitelist() {
663 [ + + + - ]: 4 : if (m_interceptor && m_cache) {
664 [ + - ]: 3 : m_interceptor->setWhitelist(
665 [ + - ]: 6 : m_cache->whitelistedDomains(),
666 [ + - ]: 6 : m_cache->whitelistedSenders());
667 [ + - + - : 6 : qCInfo(lcMailView) << "Whitelist reloaded from cache:"
+ - + + ]
668 [ + - + - : 6 : << m_cache->whitelistedDomains().size() << "domains,"
+ - ]
669 [ + - + - : 3 : << m_cache->whitelistedSenders().size() << "senders";
+ - ]
670 : : }
671 : 4 : }
672 : :
673 : : // T-122: Extract email address from "Name <email@domain>" format
674 : : // 67.B4: derive up to two initials from a From field ("Jane Doe <j@x>"
675 : : // → "JD", "jane@x" → "J"). Falls back to "?" for empty input.
676 : 81 : QString MailView::initialsForSender(const QString &fromField) {
677 : 81 : QString name = fromField;
678 : 81 : int lt = name.indexOf(QLatin1Char('<'));
679 [ + + ]: 81 : if (lt >= 0)
680 [ + - ]: 39 : name = name.left(lt);
681 [ + - ]: 81 : name.remove(QLatin1Char('"'));
682 [ + - ]: 81 : name = name.trimmed();
683 [ + + ]: 81 : if (name.isEmpty())
684 [ + - ]: 11 : name = extractEmail(fromField);
685 [ + + ]: 81 : if (name.isEmpty())
686 : 11 : return QStringLiteral("?");
687 : :
688 : : const QStringList parts =
689 [ + - ]: 210 : name.split(QRegularExpression(QStringLiteral("[\\s._@-]+")),
690 [ + - ]: 70 : Qt::SkipEmptyParts);
691 : 70 : QString initials;
692 [ + + ]: 140 : for (const QString &part : parts) {
693 [ + - ]: 137 : initials += part.at(0).toUpper();
694 [ + + ]: 137 : if (initials.size() == 2)
695 : 67 : break;
696 : : }
697 [ - + - + ]: 70 : return initials.isEmpty() ? QStringLiteral("?") : initials;
698 : 81 : }
699 : :
700 : : // 67.B4: 40x40 disc with the sender's initials; color is deterministic
701 : : // per address (ThemeManager::avatarColor)
702 : 76 : QPixmap MailView::renderAvatar(const QString &fromField) const {
703 [ + - ]: 76 : const qreal dpr = devicePixelRatioF();
704 : 76 : const int size = 40;
705 [ + - ]: 76 : QPixmap pm(qRound(size * dpr), qRound(size * dpr));
706 [ + - ]: 76 : pm.setDevicePixelRatio(dpr);
707 [ + - ]: 76 : pm.fill(Qt::transparent);
708 : :
709 [ + - ]: 76 : QPainter p(&pm);
710 [ + - ]: 76 : p.setRenderHint(QPainter::Antialiasing, true);
711 [ + - ]: 76 : p.setPen(Qt::NoPen);
712 [ + - + - : 76 : p.setBrush(ThemeManager::avatarColor(extractEmail(fromField)));
+ - + - ]
713 [ + - ]: 76 : p.drawEllipse(0, 0, size, size);
714 : :
715 [ + - + - ]: 76 : QFont f = font();
[ + - ]
716 [ + - ]: 76 : f.setPixelSize(16);
717 [ + - ]: 76 : f.setBold(true);
718 [ + - ]: 76 : p.setFont(f);
719 [ + - ]: 76 : p.setPen(Qt::white);
720 [ + - ]: 76 : p.drawText(QRect(0, 0, size, size), Qt::AlignCenter,
721 [ + - ]: 152 : initialsForSender(fromField));
722 : 76 : return pm;
723 : 76 : }
724 : :
725 : 94 : QString MailView::extractEmail(const QString &fromField) {
726 : 94 : int start = fromField.indexOf('<');
727 : 94 : int end = fromField.indexOf('>');
728 [ + + + - ]: 94 : if (start >= 0 && end > start) {
729 [ + - + - : 41 : return fromField.mid(start + 1, end - start - 1).trimmed().toLower();
+ - ]
730 : : }
731 : : // No angle brackets — assume the whole field is an email
732 [ + - + - ]: 53 : return fromField.trimmed().toLower();
733 : : }
734 : :
735 : : // T-122: Build dropdown menu for external content info bar
736 : 3 : void MailView::buildExternalContentMenu() {
737 [ + - - + : 3 : auto *menu = new QMenu(m_loadExternalBtn);
- - ]
738 : :
739 : : // Temporary load (existing behavior)
740 [ + - ]: 3 : menu->addAction(tr("Show external content in this message"),
741 [ + - ]: 3 : this, &MailView::loadExternalContent);
742 : 3 : menu->addSeparator();
743 : :
744 : : // Domain-based allow actions
745 [ + - ]: 3 : if (m_interceptor) {
746 [ + - ]: 3 : QSet<QString> blocked = m_interceptor->blockedDomains();
747 [ + - + - : 6 : for (const auto &domain : blocked) {
+ + ]
748 [ + - ]: 3 : menu->addAction(
749 [ + - ]: 6 : tr("Allow external content from %1").arg(domain),
750 [ + - ]: 6 : this, [this, domain]() {
751 [ # # ]: 0 : if (m_cache) {
752 [ # # # # ]: 0 : m_cache->addWhitelistEntry("domain", domain);
753 : 0 : emit whitelistChanged();
754 : 0 : m_externalContentOverride = true;
755 : 0 : m_interceptor->setBlockExternal(false);
756 : 0 : m_infoLabel->setVisible(false);
757 : 0 : m_loadExternalBtn->setVisible(false);
758 : 0 : renderCurrentBody();
759 : : }
760 : 0 : });
761 : : }
762 : :
763 [ - + ]: 3 : if (blocked.size() > 1) {
764 [ # # ]: 0 : menu->addAction(
765 : 0 : tr("Allow external content from all sources listed above"),
766 [ # # ]: 0 : this, [this, blocked]() {
767 [ # # ]: 0 : if (m_cache) {
768 [ # # ]: 0 : for (const auto &d : blocked)
769 [ # # # # ]: 0 : m_cache->addWhitelistEntry("domain", d);
770 : 0 : emit whitelistChanged();
771 : 0 : m_externalContentOverride = true;
772 : 0 : m_interceptor->setBlockExternal(false);
773 : 0 : m_infoLabel->setVisible(false);
774 : 0 : m_loadExternalBtn->setVisible(false);
775 : 0 : renderCurrentBody();
776 : : }
777 : 0 : });
778 : : }
779 : 3 : }
780 : :
781 : : // T-202: Store as member for manual popup (no setMenu → no native arrow)
782 [ + + ]: 3 : if (m_externalMenu)
783 : 1 : m_externalMenu->deleteLater();
784 : 3 : m_externalMenu = menu;
785 : 3 : }
786 : :
787 : : // T-304: Runtime language switching
788 : 1515 : void MailView::changeEvent(QEvent *event) {
789 [ + + ]: 1515 : if (event->type() == QEvent::LanguageChange)
790 : 8 : retranslateUi();
791 : 1515 : QWidget::changeEvent(event);
792 : 1515 : }
793 : :
794 : 8 : void MailView::retranslateUi() {
795 [ + - + - ]: 8 : m_infoLabel->setText(tr("\u26a0 External content has been blocked"));
796 [ + - + - ]: 8 : m_loadExternalBtn->setText(tr("Settings \u25BE"));
797 [ + - + - ]: 8 : m_toggleBtn->setToolTip(tr("Toggle Text/HTML (h)"));
798 [ + - + - ]: 8 : m_sourceBtn->setToolTip(tr("Show Source"));
799 : 8 : }
800 : :
801 : :
802 : : // T-351/Sprint 75: Context-sensitive mail context menu for both viewers.
803 : : // Both callers normalize to GLOBAL screen coordinates before invoking
804 : : // this function:
805 : : // - QWebEngineView path: MailWebEngineView::contextMenuEvent emits
806 : : // event->globalPos() directly (see MailWebEngineView above and the
807 : : // connect at ensureWebEngine()).
808 : : // - QTextBrowser path: onPlainTextContextMenuRequested() maps the
809 : : // viewport-local customContextMenuRequested coordinate via
810 : : // viewport()->mapToGlobal() before calling this function.
811 : : // Only the plaintext link detection maps back to viewport-local
812 : : // coordinates for anchorAt().
813 : 2 : void MailView::onPlainTextContextMenuRequested(const QPoint &localPos) {
814 : : // customContextMenuRequested delivers viewport-local coordinates for
815 : : // QAbstractScrollArea-derived widgets (QTextBrowser). Convert to
816 : : // global screen coordinates so showMailContextMenu() can interpret
817 : : // the parameter uniformly from both callers.
818 [ + - + - : 2 : showMailContextMenu(m_textBrowser->viewport()->mapToGlobal(localPos));
+ - ]
819 : 2 : }
820 : :
821 : 2 : void MailView::showMailContextMenu(const QPoint &globalPos) {
822 [ + - ]: 2 : QMenu menu(this);
823 : :
824 : : // Sprint 75: opt-in test seam. When set, record the exec coordinate
825 : : // and return without showing the menu — keeps unit tests deterministic.
826 [ + + ]: 2 : if (m_interceptMenuExec) {
827 : 1 : m_lastMenuExecGlobalPos = globalPos;
828 : 1 : return;
829 : : }
830 : :
831 : : // Link-specific actions (WebEngine HTML viewer)
832 [ - + - - : 1 : if (m_webView && m_stack->currentIndex() == 1) {
- - - + ]
833 [ # # ]: 0 : auto *request = m_webView->lastContextMenuRequest();
834 [ # # # # : 0 : if (request && request->linkUrl().isValid()) {
# # # # #
# # # #
# ]
835 [ # # ]: 0 : QUrl linkUrl = request->linkUrl();
836 [ # # # # ]: 0 : menu.addAction(tr("Open link in browser"), this, [linkUrl]() {
837 : : // T-512: Apply same scheme allowlist as plaintext links
838 [ # # # # : 0 : if (isAllowedExternalScheme(linkUrl.scheme().toLower())) {
# # # # ]
839 : 0 : QDesktopServices::openUrl(linkUrl);
840 : : } else {
841 [ # # # # : 0 : qCWarning(lcMailView) << "Blocked URL with disallowed scheme:"
# # # # ]
842 [ # # ]: 0 : << linkUrl;
843 : : }
844 : 0 : });
845 [ # # # # ]: 0 : menu.addAction(tr("Copy link address"), this, [linkUrl]() {
846 [ # # # # ]: 0 : QApplication::clipboard()->setText(linkUrl.toString());
847 : 0 : });
848 [ # # ]: 0 : menu.addSeparator();
849 : 0 : }
850 : : }
851 : :
852 : : // Link-specific actions (QTextBrowser plain text viewer)
853 [ + - + - : 1 : if (m_textBrowser && m_stack->currentIndex() == 0) {
+ - + - ]
854 : : // globalPos is global screen coordinates; anchorAt() needs
855 : : // viewport-local coordinates. Use viewport()->mapFromGlobal() (not
856 : : // the widget map) because customContextMenuRequested and anchorAt
857 : : // both operate in viewport coordinates for QAbstractScrollArea.
858 [ + - + - ]: 1 : const QPoint localPos = m_textBrowser->viewport()->mapFromGlobal(globalPos);
859 [ + - ]: 1 : QString anchor = m_textBrowser->anchorAt(localPos);
860 [ + - ]: 1 : if (!anchor.isEmpty()) {
861 [ + - ]: 1 : QUrl linkUrl(anchor);
862 [ + - + - ]: 1 : menu.addAction(tr("Open link in browser"), this, [linkUrl]() {
863 [ # # # # : 0 : if (isAllowedExternalScheme(linkUrl.scheme().toLower()))
# # # # ]
864 : 0 : QDesktopServices::openUrl(linkUrl);
865 : 0 : });
866 [ + - + - ]: 1 : menu.addAction(tr("Copy link address"), this, [anchor]() {
867 : 1 : QApplication::clipboard()->setText(anchor);
868 : 1 : });
869 [ + - ]: 1 : menu.addSeparator();
870 : 1 : }
871 : 1 : }
872 : :
873 : : // Copy text action
874 [ + - + - ]: 1 : menu.addAction(tr("Copy"), this, [this]() {
875 [ - + - - : 1 : if (m_stack->currentIndex() == 1 && m_webView) {
- + ]
876 : 0 : m_webView->triggerPageAction(QWebEnginePage::Copy);
877 [ + - ]: 1 : } else if (m_textBrowser) {
878 : 1 : m_textBrowser->copy();
879 : : }
880 : 1 : });
881 [ + - ]: 1 : menu.addSeparator();
882 : :
883 : : // Mail actions (only if a mail is displayed)
884 [ - + - - : 1 : if (!m_currentHeader.subject.isEmpty() || !m_currentBody.textPlain.isEmpty()) {
+ - ]
885 [ + - + - ]: 2 : menu.addAction(tr("Reply"), this, [this]() { emit replyRequested(); });
886 [ + - + - ]: 2 : menu.addAction(tr("Reply All"), this, [this]() { emit replyAllRequested(); });
887 [ + - + - ]: 2 : menu.addAction(tr("Forward"), this, [this]() { emit forwardRequested(); });
888 [ + - ]: 1 : menu.addSeparator();
889 [ + - + - ]: 2 : menu.addAction(tr("Move to..."), this, [this]() { emit moveRequested(); });
890 [ + - + - ]: 2 : menu.addAction(tr("Archive"), this, [this]() { emit archiveRequested(); });
891 [ + - + - ]: 2 : menu.addAction(tr("Delete"), this, [this]() { emit deleteRequested(); });
892 [ + - ]: 1 : menu.addSeparator();
893 [ + - + - ]: 1 : menu.addAction(tr("View Source"), this, &MailView::showSource);
894 : : }
895 : :
896 [ + - ]: 1 : menu.exec(globalPos);
897 [ + + ]: 2 : }
898 : :
899 : : // T-352: HTML sanitizer — removes dangerous tags and attributes from mail HTML.
900 : : // Defense-in-depth: JavaScript is already disabled in WebEngine settings,
901 : : // but the sanitizer protects against potential Chromium bypass bugs.
902 : : // T-608: Old regex-based sanitizeMailHtml() removed.
903 : : // HTML sanitization now handled by HtmlSanitizer (DOMPurify).
|