MailJD nbsp;·nbsp; Test Dashboard nbsp;·nbsp; Coverage
LCOV - code coverage report
Current view: top level - ui - MailView.cpp (source / functions) Coverage Total Hit
Test: MailJD Coverage (Unit + E2E) Lines: 82.5 % 538 444
Test Date: 2026-06-21 21:10:19 Functions: 80.0 % 45 36
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 41.2 % 1194 492

             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).
        

Generated by: LCOV version 2.0-1