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 % 554 457
Test Date: 2026-07-27 17:53:44 Functions: 80.9 % 47 38
Legend: Lines:     hit not hit
Branches: + taken - not taken # not executed
Branches: 41.0 % 1194 490

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

Generated by: LCOV version 2.0-1