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