#include "ui/readerview.h" #include "services/mimestorage.h" #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include #include "db/dao/mailitemdao.h" ReaderView::ReaderView(QWidget *parent) : QWidget(parent) { setupUI(); applyEmailCss(); } void ReaderView::setupUI() { QVBoxLayout *mainLayout = new QVBoxLayout(this); mainLayout->setContentsMargins(10, 0, 10, 0); mainLayout->setSpacing(0); // ===== Subject Section (independent, with shadow) ===== QFrame *subjectFrame = new QFrame(); subjectFrame->setObjectName("SubjectFrame"); subjectFrame->setStyleSheet( "QFrame#SubjectFrame {" " background: white;" " border: none;" " border: 1px solid #e0e0e0;" " border-radius: 4px;" "}" ); // Add shadow effect QGraphicsDropShadowEffect *shadowEffect = new QGraphicsDropShadowEffect(this); shadowEffect->setBlurRadius(22); shadowEffect->setOffset(0, 6); shadowEffect->setColor(QColor(0, 0, 0, 40)); subjectFrame->setGraphicsEffect(shadowEffect); QVBoxLayout *subjectLayout = new QVBoxLayout(subjectFrame); subjectLayout->setContentsMargins(16, 16, 16, 16); subjectLayout->setSpacing(8); QHBoxLayout *subjectRow = new QHBoxLayout(); subjectRow->setSpacing(12); m_subjectLabel = new QLabel(); m_subjectLabel->setText("(Sin asunto)"); QFont subjectFont = m_subjectLabel->font(); subjectFont.setBold(true); subjectFont.setPointSize(12); m_subjectLabel->setFont(subjectFont); m_subjectLabel->setWordWrap(true); m_subjectLabel->setStyleSheet("color: #1d1d1f;"); m_subjectLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); subjectRow->addWidget(m_subjectLabel, 1); subjectLayout->addLayout(subjectRow); mainLayout->addWidget(subjectFrame); // Add spacing after subject frame so shadow is visible mainLayout->addSpacing(12); // ===== Toolbar (zoom, find, images) ===== m_toolbar = new QFrame(); m_toolbar->setFixedHeight(36); m_toolbar->setStyleSheet("QFrame { background: #fafafa; border-bottom: 1px solid #e0e0e0; }"); QHBoxLayout *toolbarLayout = new QHBoxLayout(m_toolbar); toolbarLayout->setContentsMargins(8, 4, 8, 4); toolbarLayout->setSpacing(8); // Load images button (shows when external images are blocked) m_loadImagesButton = new QToolButton(); m_loadImagesButton->setText("🖼 Cargar imágenes"); m_loadImagesButton->setToolTip("Cargar imágenes externas bloqueadas (tracking pixels, etc.)"); m_loadImagesButton->setFixedHeight(28); m_loadImagesButton->setVisible(false); m_loadImagesButton->setStyleSheet( "QToolButton {" " background: #e3f2fd;" " border: 1px solid #1976D2;" " border-radius: 4px;" " padding: 4px 10px;" " font-size: 11px;" " color: #1976D2;" " font-weight: 500;" "}" "QToolButton:hover {" " background: #bbdefb;" "}" ); connect(m_loadImagesButton, &QToolButton::clicked, [this]() { m_allowExternalImages = true; m_loadImagesButton->setVisible(false); refreshCurrentMail(); }); m_zoomOutButton = new QToolButton(); m_zoomOutButton->setText("−"); m_zoomOutButton->setFixedSize(28, 28); m_zoomOutButton->setToolTip("Zoom out (Ctrl+-)"); connect(m_zoomOutButton, &QToolButton::clicked, [this]() { m_zoomFactor = qMax(0.5, m_zoomFactor - 0.1); updateZoom(); }); m_zoomLabel = new QLabel("100%"); m_zoomLabel->setFixedWidth(50); m_zoomLabel->setAlignment(Qt::AlignCenter); m_zoomLabel->setStyleSheet("font-size: 11px; color: #555;"); m_zoomInButton = new QToolButton(); m_zoomInButton->setText("+"); m_zoomInButton->setFixedSize(28, 28); m_zoomInButton->setToolTip("Zoom in (Ctrl++)"); connect(m_zoomInButton, &QToolButton::clicked, [this]() { m_zoomFactor = qMin(3.0, m_zoomFactor + 0.1); updateZoom(); }); m_zoomResetButton = new QToolButton(); m_zoomResetButton->setText("100%"); m_zoomResetButton->setFixedSize(50, 28); m_zoomResetButton->setToolTip("Reset zoom (Ctrl+0)"); connect(m_zoomResetButton, &QToolButton::clicked, [this]() { m_zoomFactor = 1.0; updateZoom(); }); toolbarLayout->addWidget(m_loadImagesButton); toolbarLayout->addStretch(); toolbarLayout->addWidget(m_zoomOutButton); toolbarLayout->addWidget(m_zoomLabel); toolbarLayout->addWidget(m_zoomInButton); toolbarLayout->addWidget(m_zoomResetButton); toolbarLayout->addSpacing(20); // Find bar (hidden by default) m_findBar = new QFrame(); m_findBar->setFixedHeight(36); m_findBar->setVisible(false); m_findBar->setStyleSheet("QFrame { background: #fff3cd; border-bottom: 1px solid #ffc107; }"); QHBoxLayout *findLayout = new QHBoxLayout(m_findBar); findLayout->setContentsMargins(8, 4, 8, 4); findLayout->setSpacing(8); QLabel *findLabel = new QLabel("Buscar:"); findLabel->setStyleSheet("font-weight: bold; color: #856404;"); m_findInput = new QLineEdit(); m_findInput->setPlaceholderText("Buscar en el mensaje..."); m_findInput->setFixedHeight(28); m_findInput->setStyleSheet("QLineEdit { border: 1px solid #ffc107; border-radius: 3px; padding: 2px 8px; background: white; }"); connect(m_findInput, &QLineEdit::textChanged, [this](const QString &text) { if (!text.isEmpty()) { bool found = m_bodyViewer->find(text, QTextDocument::FindCaseSensitively); if (!found) { m_bodyViewer->moveCursor(QTextCursor::Start); m_bodyViewer->find(text, QTextDocument::FindCaseSensitively); } } // Update count QString allText = m_bodyViewer->toPlainText(); int count = 0; int pos = 0; while ((pos = allText.indexOf(text, pos, Qt::CaseInsensitive)) != -1) { count++; pos += text.length(); } m_findCountLabel->setText(QString("%1 coincidencias").arg(count)); }); connect(m_findInput, &QLineEdit::returnPressed, [this]() { m_bodyViewer->find(m_findInput->text(), QTextDocument::FindCaseSensitively); }); m_findPrevButton = new QToolButton(); m_findPrevButton->setText("▲"); m_findPrevButton->setFixedSize(28, 28); m_findPrevButton->setToolTip("Anterior (Shift+Enter)"); connect(m_findPrevButton, &QToolButton::clicked, [this]() { m_bodyViewer->find(m_findInput->text(), QTextDocument::FindBackward | QTextDocument::FindCaseSensitively); }); m_findNextButton = new QToolButton(); m_findNextButton->setText("▼"); m_findNextButton->setFixedSize(28, 28); m_findNextButton->setToolTip("Siguiente (Enter)"); connect(m_findNextButton, &QToolButton::clicked, [this]() { m_bodyViewer->find(m_findInput->text(), QTextDocument::FindCaseSensitively); }); m_findCloseButton = new QToolButton(); m_findCloseButton->setText("✕"); m_findCloseButton->setFixedSize(28, 28); m_findCloseButton->setToolTip("Cerrar búsqueda (Esc)"); connect(m_findCloseButton, &QToolButton::clicked, [this]() { m_findBar->setVisible(false); m_findInput->clear(); m_bodyViewer->moveCursor(QTextCursor::End); }); m_findCountLabel = new QLabel(); m_findCountLabel->setStyleSheet("color: #856404; font-size: 11px;"); findLayout->addWidget(findLabel); findLayout->addWidget(m_findInput, 1); findLayout->addWidget(m_findPrevButton); findLayout->addWidget(m_findNextButton); findLayout->addWidget(m_findCountLabel); findLayout->addWidget(m_findCloseButton); mainLayout->addWidget(m_toolbar); mainLayout->addWidget(m_findBar); // ===== Header Section ===== m_headerWidget = new QWidget(); m_headerWidget->setStyleSheet("QWidget { background: white; border-bottom: 1px solid #e0e0e0; }"); QVBoxLayout *headerLayout = new QVBoxLayout(m_headerWidget); headerLayout->setContentsMargins(16, 12, 16, 12); headerLayout->setSpacing(8); // Avatar + From / To / Date row QHBoxLayout *metaRow = new QHBoxLayout(); metaRow->setSpacing(16); // Avatar on the left m_avatarLabel = new QLabel(); m_avatarLabel->setFixedSize(40, 40); m_avatarLabel->setAlignment(Qt::AlignCenter); m_avatarLabel->setStyleSheet("QLabel { background: #1976D2; color: white; border-radius: 20px; font-weight: bold; font-size: 14px; }"); metaRow->addWidget(m_avatarLabel, 0, Qt::AlignTop); // From / To / Date on the right of avatar QVBoxLayout *metaRightLayout = new QVBoxLayout(); metaRightLayout->setSpacing(2); m_fromLabel = new QLabel(); m_fromLabel->setStyleSheet("color: #333; font-size: 13px;"); m_fromLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); m_fromLabel->setCursor(Qt::PointingHandCursor); connect(m_fromLabel, &QLabel::linkActivated, [this](const QString &link) { QDesktopServices::openUrl(QUrl(link)); }); metaRightLayout->addWidget(m_fromLabel); m_toLabel = new QLabel(); m_toLabel->setStyleSheet("color: #666; font-size: 12px;"); m_toLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); m_toLabel->setWordWrap(true); metaRightLayout->addWidget(m_toLabel); m_dateLabel = new QLabel(); m_dateLabel->setStyleSheet("color: #888; font-size: 12px;"); metaRightLayout->addWidget(m_dateLabel); metaRow->addLayout(metaRightLayout, 1); headerLayout->addLayout(metaRow); // Actions row QHBoxLayout *actionsLayout = new QHBoxLayout(); actionsLayout->setSpacing(4); auto createActionButton = [&](const QString &text, const QString &tooltip, std::function callback) -> QToolButton* { QToolButton *btn = new QToolButton(); btn->setText(text); btn->setToolTip(tooltip); btn->setFixedHeight(32); btn->setMinimumWidth(80); btn->setStyleSheet( "QToolButton {" " background: transparent;" " border: 1px solid #d1d1d6;" " border-radius: 4px;" " padding: 4px 12px;" " font-size: 12px;" " color: #333;" "}" "QToolButton:hover {" " background: #f0f0f0;" " border-color: #bbb;" "}" "QToolButton:pressed {" " background: #e0e0e0;" "}" ); connect(btn, &QToolButton::clicked, [callback]() { callback(); }); return btn; }; m_replyButton = createActionButton("↩ Responder", "Responder (Ctrl+R)", [this]() { onReplyClicked(); }); m_forwardButton = createActionButton("⤴ Reenviar", "Reenviar (Ctrl+Shift+F)", [this]() { onForwardClicked(); }); m_deleteButton = createActionButton("🗑 Eliminar", "Eliminar (Del)", [this]() { onDeleteClicked(); }); m_deleteButton->setStyleSheet(m_deleteButton->styleSheet() + "QToolButton { color: #d32f2f; border-color: #ef9a9a; } QToolButton:hover { background: #fdeaea; border-color: #ef5350; }"); m_detachButton = createActionButton("↗ Ventana", "Abrir en ventana independiente", [this]() { onDetachClicked(); }); m_moreButton = new QToolButton(); m_moreButton->setText("⋯"); m_moreButton->setFixedSize(32, 32); m_moreButton->setToolTip("Más opciones"); m_moreButton->setPopupMode(QToolButton::InstantPopup); QMenu *moreMenu = new QMenu(this); moreMenu->addAction("Copiar asunto", [this]() { if (m_currentMailId >= 0) { std::optional item = MailItemDao::findById(m_currentMailId); if (item.has_value()) QApplication::clipboard()->setText(item->subject()); } }); moreMenu->addAction("Copiar remitente", [this]() { if (m_currentMailId >= 0) { std::optional item = MailItemDao::findById(m_currentMailId); if (item.has_value()) QApplication::clipboard()->setText(item->sender()); } }); moreMenu->addAction("Ver código fuente", [this]() { if (m_currentMailId < 0) return; std::optional item = MailItemDao::findById(m_currentMailId); if (!item.has_value()) return; QByteArray raw; if (!item->fileId().isEmpty()) { MimeStorageService storage; raw = storage.readEmlFile(item->fileId()); } if (raw.isEmpty()) raw = item->rawMime(); if (raw.isEmpty()) { QMessageBox::information(this, tr("Código fuente"), tr("No hay código fuente disponible para este correo.")); return; } QDialog dlg(this); dlg.setWindowTitle(tr("Código fuente")); dlg.resize(760, 560); QVBoxLayout *lay = new QVBoxLayout(&dlg); QPlainTextEdit *view = new QPlainTextEdit(&dlg); view->setReadOnly(true); view->setPlainText(QString::fromUtf8(raw)); view->setStyleSheet("font-family: 'Consolas','Menlo',monospace; font-size: 12px;"); lay->addWidget(view); QDialogButtonBox *buttons = new QDialogButtonBox(QDialogButtonBox::Close, &dlg); connect(buttons, &QDialogButtonBox::rejected, &dlg, &QDialog::reject); lay->addWidget(buttons); dlg.exec(); }); moreMenu->addSeparator(); moreMenu->addAction("Marcar como no leído", [this]() { if (m_currentMailId < 0) return; auto item = MailItemDao::findById(m_currentMailId); if (item.has_value()) { item->setRead(false); MailItemDao::update(*item); emit markUnreadRequested(m_currentMailId); } }); moreMenu->addAction("Marcar como spam", [this]() { if (m_currentMailId < 0) return; QMessageBox::information(this, tr("Marcar como spam"), tr("Función: mover a carpeta «Spam». Disponible en una próxima versión.")); }); m_moreButton->setMenu(moreMenu); m_moreButton->setStyleSheet( "QToolButton { background: transparent; border: 1px solid #d1d1d6; border-radius: 4px; font-size: 16px; }" "QToolButton:hover { background: #f0f0f0; }" ); actionsLayout->addWidget(m_replyButton); actionsLayout->addWidget(m_forwardButton); actionsLayout->addWidget(m_deleteButton); actionsLayout->addWidget(m_detachButton); actionsLayout->addStretch(); actionsLayout->addWidget(m_moreButton); headerLayout->addLayout(actionsLayout); // ===== Body Viewer (inside header widget) ===== setupBodyViewer(); headerLayout->addWidget(m_scrollArea, 1); mainLayout->addWidget(m_headerWidget); // ===== Attachments Area ===== setupAttachmentsArea(); mainLayout->addWidget(m_attachmentsFrame); // Shortcuts QShortcut *findShortcut = new QShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_F), this); connect(findShortcut, &QShortcut::activated, [this]() { m_findBar->setVisible(true); m_findInput->setFocus(); }); QShortcut *zoomInShortcut = new QShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_Plus), this); connect(zoomInShortcut, &QShortcut::activated, [this]() { m_zoomFactor = qMin(3.0, m_zoomFactor + 0.1); updateZoom(); }); QShortcut *zoomOutShortcut = new QShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_Minus), this); connect(zoomOutShortcut, &QShortcut::activated, [this]() { m_zoomFactor = qMax(0.5, m_zoomFactor - 0.1); updateZoom(); }); QShortcut *zoomResetShortcut = new QShortcut(QKeySequence(Qt::ControlModifier | Qt::Key_0), this); connect(zoomResetShortcut, &QShortcut::activated, [this]() { m_zoomFactor = 1.0; updateZoom(); }); } void ReaderView::setupBodyViewer() { m_scrollArea = new QScrollArea(); m_scrollArea->setWidgetResizable(true); m_scrollArea->setFrameStyle(QFrame::NoFrame); m_scrollArea->setHorizontalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_scrollArea->setVerticalScrollBarPolicy(Qt::ScrollBarAsNeeded); m_scrollArea->setStyleSheet("QScrollArea { background: white; border: none; }"); m_bodyContainer = new QWidget(); m_bodyLayout = new QVBoxLayout(m_bodyContainer); m_bodyLayout->setContentsMargins(16, 16, 16, 16); m_bodyLayout->setSpacing(0); m_bodyViewer = new QTextBrowser(); m_bodyViewer->setOpenExternalLinks(false); // We'll handle link clicks securely m_bodyViewer->setFrameStyle(QFrame::NoFrame); m_bodyViewer->setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Preferred); m_bodyViewer->setStyleSheet("QTextBrowser { background: transparent; border: none; font-family: 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 13px; line-height: 1.5; color: #333; }"); m_bodyViewer->setMouseTracking(true); // Handle link clicks securely connect(m_bodyViewer, &QTextBrowser::anchorClicked, [this](const QUrl &url) { QString scheme = url.scheme().toLower(); if (scheme == "http" || scheme == "https") { // Ask before opening external links if (QMessageBox::question(this, "Enlace externo", QString("Abrir enlace externo?\n%1").arg(url.toString()), QMessageBox::Yes | QMessageBox::No) == QMessageBox::Yes) { QDesktopServices::openUrl(url); } } else if (scheme == "mailto") { QDesktopServices::openUrl(url); } }); // Context menu for copy, etc. m_bodyViewer->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_bodyViewer, &QWidget::customContextMenuRequested, [this](const QPoint &pos) { QMenu *menu = m_bodyViewer->createStandardContextMenu(pos); menu->addSeparator(); menu->addAction("Seleccionar todo", [this]() { m_bodyViewer->selectAll(); }); menu->addAction("Buscar...", [this]() { m_findBar->setVisible(true); m_findInput->setFocus(); }); menu->exec(m_bodyViewer->mapToGlobal(pos)); delete menu; }); m_bodyLayout->addWidget(m_bodyViewer); m_scrollArea->setWidget(m_bodyContainer); } void ReaderView::setupAttachmentsArea() { m_attachmentsFrame = new QFrame(); m_attachmentsFrame->setVisible(false); m_attachmentsFrame->setStyleSheet("QFrame { background: #fafafa; border-top: 1px solid #e0e0e0; border-bottom: 1px solid #e0e0e0; }"); m_attachmentsLayout = new QVBoxLayout(m_attachmentsFrame); m_attachmentsLayout->setContentsMargins(16, 8, 16, 8); m_attachmentsLayout->setSpacing(8); m_attachmentsHeader = new QLabel("Adjuntos"); QFont hdrFont = m_attachmentsHeader->font(); hdrFont.setBold(true); hdrFont.setPointSize(11); m_attachmentsHeader->setFont(hdrFont); m_attachmentsHeader->setStyleSheet("color: #555;"); m_attachmentsLayout->addWidget(m_attachmentsHeader); m_attachmentList = new QListWidget(); m_attachmentList->setFixedHeight(80); m_attachmentList->setStyleSheet( "QListWidget { background: white; border: 1px solid #e0e0e0; border-radius: 6px; padding: 4px; }" "QListWidget::item { border: none; padding: 6px 8px; border-radius: 4px; }" "QListWidget::item:hover { background: #f0f0f0; }" "QListWidget::item:selected { background: #e3f2fd; color: #1976D2; }" ); m_attachmentList->setSpacing(2); m_attachmentList->setContextMenuPolicy(Qt::CustomContextMenu); connect(m_attachmentList, &QListWidget::customContextMenuRequested, [this](const QPoint &pos) { QListWidgetItem *item = m_attachmentList->itemAt(pos); if (item) { QString path = item->data(Qt::UserRole).toString(); QString name = item->text(); showAttachmentContextMenu(pos, path, name); } }); connect(m_attachmentList, &QListWidget::itemDoubleClicked, [this](QListWidgetItem *item) { QString path = item->data(Qt::UserRole).toString(); if (!path.isEmpty()) { emit openAttachmentRequested(path); QDesktopServices::openUrl(QUrl::fromLocalFile(path)); } }); m_attachmentsLayout->addWidget(m_attachmentList); } void ReaderView::setupFindBar() { // Already set up in setupUI } void ReaderView::applyEmailCss() { // Base CSS for email rendering - injected into document m_baseCss = R"( /* Email CSS Reset & Base */ body { margin: 0; padding: 0; font-family: 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 13px; line-height: 1.55; color: #333; background: white; } .email-wrapper { max-width: 100%; margin: 0 auto; } .email-body { word-wrap: break-word; overflow-wrap: break-word; } /* Typography */ h1, h2, h3, h4, h5, h6 { margin: 16px 0 8px; font-weight: 600; line-height: 1.3; color: #1d1d1f; } h1 { font-size: 28px; } h2 { font-size: 24px; } h3 { font-size: 20px; } h4 { font-size: 16px; } p { margin: 0 0 12px; } a { color: #1976D2; text-decoration: none; } a:hover { text-decoration: underline; } /* Lists */ ul, ol { margin: 8px 0; padding-left: 24px; } li { margin: 4px 0; } /* Blockquotes (replies/forwards) */ blockquote { margin: 12px 0; padding: 8px 16px; border-left: 3px solid #1976D2; background: #f5f5f5; color: #555; font-style: italic; } blockquote blockquote { border-left-color: #4CAF50; background: #f1f8e9; } blockquote blockquote blockquote { border-left-color: #FF9800; background: #fff8e1; } /* Tables */ table { border-collapse: collapse; width: 100%; max-width: 100%; margin: 12px 0; } th, td { border: 1px solid #e0e0e0; padding: 8px 12px; text-align: left; } th { background: #f5f5f5; font-weight: 600; } tr:nth-child(even) td { background: #fafafa; } /* Images */ img { max-width: 100%; height: auto; border-radius: 4px; } img[src^="cid:"] { opacity: 0.6; } /* Embedded images placeholder */ /* Code */ code { background: #f5f5f5; padding: 2px 6px; border-radius: 3px; font-family: 'Consolas', 'Monaco', monospace; font-size: 12px; } pre { background: #2d2d2d; color: #f8f8f2; padding: 16px; border-radius: 6px; overflow-x: auto; margin: 12px 0; } pre code { background: transparent; padding: 0; color: inherit; } /* Horizontal rule */ hr { border: none; border-top: 1px solid #e0e0e0; margin: 16px 0; } /* Email-specific */ .email-header { background: #fafafa; padding: 12px 16px; border-bottom: 1px solid #e0e0e0; margin: -16px -16px 16px; font-size: 12px; color: #666; } .email-signature { border-top: 1px solid #e0e0e0; margin-top: 24px; padding-top: 12px; color: #888; font-size: 12px; } .email-quote { margin: 16px 0; padding-left: 16px; border-left: 3px solid #ccc; color: #666; } /* Dark mode adjustments (applied via class on body) */ body.dark { background: #1e1e1e; color: #e0e0e0; } body.dark h1, body.dark h2, body.dark h3, body.dark h4 { color: #fff; } body.dark blockquote { background: #2a2a2a; border-left-color: #64b5f6; color: #bbb; } body.dark blockquote blockquote { background: #263238; border-left-color: #81c784; } body.dark blockquote blockquote blockquote { background: #3e2723; border-left-color: #ffb74d; } body.dark table, body.dark th, body.dark td { border-color: #333; } body.dark th { background: #2a2a2a; } body.dark tr:nth-child(even) td { background: #252525; } body.dark code { background: #2a2a2a; color: #e0e0e0; } body.dark pre { background: #1a1a1a; } body.dark .email-header { background: #2a2a2a; border-bottom-color: #333; color: #aaa; } body.dark .email-signature { border-top-color: #333; color: #aaa; } body.dark .email-quote { border-left-color: #444; color: #aaa; } body.dark a { color: #90caf9; } body.dark img[src^="cid:"] { opacity: 0.4; } )"; } void ReaderView::updateZoom() { m_zoomLabel->setText(QString("%1%").arg(qRound(m_zoomFactor * 100))); QFont f = m_bodyViewer->font(); f.setPointSizeF(13 * m_zoomFactor); m_bodyViewer->setFont(f); // Also scale the whole container for better zoom using document zoom // QTextBrowser doesn't have setZoomFactor, but we can use zoom on the document // The font scaling above handles it } QString ReaderView::sanitizeHtml(const QString &html) { if (html.isEmpty()) return ""; QString result = html; // Wrap in email wrapper if not already structured if (!result.contains("
%1
").arg(result); } // Security: Remove scripts, iframes, event handlers result.remove(QRegularExpression("(?is)]*>.*?")); result.remove(QRegularExpression("(?is)]*>.*?")); result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*\"[^\"]*\"")); result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*'[^']*'")); result.remove(QRegularExpression("(?is)\\s(on\\w+)\\s*=\\s*\\w+")); // Block external images (tracking pixels, etc.) - replace with placeholder, unless allowed if (!m_allowExternalImages) { // Match images with http/https/data URLs in src attribute QRegularExpression extImgRegex("(?i)]*src\\s*=\\s*[\"'](?:https?|data):[^\"']*[\"'][^>]*)>"); int beforeCount = result.count("data-blocked=\"true\""); result.replace(extImgRegex, ""); int afterCount = result.count("data-blocked=\"true\""); qDebug() << "[ReaderView] External images blocked:" << (afterCount - beforeCount); // Show load images button if there were blocked images if (m_loadImagesButton && result.contains("data-blocked=\"true\"")) { m_loadImagesButton->setVisible(true); } } // Add dark mode class if needed if (m_darkMode) { result.replace(""); result.replace("", ""); } // Inject base CSS QString styleTag = QString("").arg(m_baseCss); if (result.contains("", Qt::CaseInsensitive)) { result.replace(QRegularExpression("(?i)"), styleTag + ""); } else if (result.contains("]*>"), QString("%1").arg(styleTag)); } else { result = styleTag + result; } return result; } QString ReaderView::formatAddress(const QString &raw) { if (raw.isEmpty()) return ""; // Extract name and email from "Name " format QRegularExpression re("^\\s*(.*?)\\s*<([^>]+)>\\s*$"); QRegularExpressionMatch match = re.match(raw); if (match.hasMatch()) { QString name = match.captured(1).trimmed(); QString email = match.captured(2).trimmed(); if (!name.isEmpty()) { return QString("%2 <%1>").arg(email, name); } return QString("%1").arg(email); } // Just an email if (raw.contains("@")) { return QString("%1").arg(raw); } // Just a name return raw.toHtmlEscaped(); } QString ReaderView::formatDate(const QDateTime &dt) { if (!dt.isValid()) return ""; QDateTime now = QDateTime::currentDateTime(); if (dt.date() == now.date()) { return dt.toString("'Hoy' hh:mm"); } else if (dt.date() == now.date().addDays(-1)) { return dt.toString("'Ayer' hh:mm"); } else if (dt.date().year() == now.date().year()) { return dt.toString("ddd, d MMM 'a las' hh:mm"); } else { return dt.toString("ddd, d MMM yyyy 'a las' hh:mm"); } } QIcon ReaderView::mimeIcon(const QString &mimeType) { QStyle *style = QApplication::style(); if (mimeType.startsWith("image/")) return QIcon::fromTheme("image-x-generic", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.startsWith("application/pdf")) return QIcon::fromTheme("application-pdf", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.startsWith("application/msword") || mimeType.contains("wordprocessingml")) return QIcon::fromTheme("application-msword", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.contains("spreadsheet") || mimeType.contains("excel")) return QIcon::fromTheme("application-vnd.ms-excel", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.contains("presentation") || mimeType.contains("powerpoint")) return QIcon::fromTheme("application-vnd.ms-powerpoint", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.startsWith("text/")) return QIcon::fromTheme("text-plain", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.startsWith("audio/")) return QIcon::fromTheme("audio-x-generic", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.startsWith("video/")) return QIcon::fromTheme("video-x-generic", style->standardIcon(QStyle::SP_FileIcon)); if (mimeType.contains("zip") || mimeType.contains("compressed") || mimeType.contains("archive")) return QIcon::fromTheme("package-x-generic", style->standardIcon(QStyle::SP_FileIcon)); return QIcon::fromTheme("unknown", style->standardIcon(QStyle::SP_FileIcon)); } void ReaderView::showAttachmentContextMenu(const QPoint &pos, const QString &path, const QString &name) { QMenu menu(this); QAction *openAct = menu.addAction("Abrir", [this, path]() { emit openAttachmentRequested(path); QDesktopServices::openUrl(QUrl::fromLocalFile(path)); }); openAct->setIcon(mimeIcon(QMimeDatabase().mimeTypeForFile(path).name())); QAction *saveAct = menu.addAction("Guardar como...", [this, path, name]() { QString savePath = QFileDialog::getSaveFileName(this, "Guardar adjunto", QDir::homePath() + "/" + name); if (!savePath.isEmpty()) { QFile::copy(path, savePath); } }); saveAct->setIcon(QIcon::fromTheme("document-save", QApplication::style()->standardIcon(QStyle::SP_DialogSaveButton))); QAction *copyPathAct = menu.addAction("Copiar ruta", [path]() { QApplication::clipboard()->setText(path); }); copyPathAct->setIcon(QIcon::fromTheme("edit-copy", QApplication::style()->standardIcon(QStyle::SP_DialogApplyButton))); menu.addSeparator(); QAction *deleteAct = menu.addAction("Eliminar adjunto", [this, name]() { // TODO: Implement attachment deletion from stored mail QMessageBox::information(this, "No implementado", "Eliminar adjuntos del almacenamiento local aún no está implementado."); }); deleteAct->setIcon(QIcon::fromTheme("edit-delete", QApplication::style()->standardIcon(QStyle::SP_TrashIcon))); menu.exec(m_attachmentList->mapToGlobal(pos)); } void ReaderView::setMailItem(const MailItem* item) { if (!item) { m_currentMailId = -1; m_allowExternalImages = false; m_loadImagesButton->setVisible(false); m_subjectLabel->setText("(Sin asunto)"); m_avatarLabel->setText("?"); m_fromLabel->setText("Remitente: —"); m_toLabel->setText("Para: —"); m_dateLabel->setText("Fecha: —"); m_attachmentList->clear(); m_attachmentsFrame->setVisible(false); m_bodyViewer->setHtml(""); return; } m_currentMailId = item->id(); m_allowExternalImages = false; m_loadImagesButton->setVisible(false); // Subject m_subjectLabel->setText(item->subject().isEmpty() ? "(Sin asunto)" : item->subject()); // Avatar - use first letter of sender name QString sender = item->sender(); QString avatarChar = "?"; QRegularExpression re("^\\s*(.*?)\\s*<[^>]+>\\s*$"); QRegularExpressionMatch match = re.match(sender); if (match.hasMatch()) { QString name = match.captured(1).trimmed(); if (!name.isEmpty()) avatarChar = name[0].toUpper(); } else if (!sender.isEmpty() && sender.contains("@")) { avatarChar = sender[0].toUpper(); } m_avatarLabel->setText(avatarChar); // From m_fromLabel->setText("De: " + formatAddress(sender)); // To - MailItem doesn't have recipients easily accessible, skip for now m_toLabel->setText("Para: —"); // TODO: fetch from MailItem or DB // Date m_dateLabel->setText("Fecha: " + formatDate(item->date())); // Attachments m_attachmentList->clear(); const QVector attachments = MailItemDao::attachmentsForMail(item->id()); for (const StoredAttachmentRecord &attachment : attachments) { QListWidgetItem *listItem = new QListWidgetItem(m_attachmentList); QWidget *widget = new QWidget(); QHBoxLayout *layout = new QHBoxLayout(widget); layout->setContentsMargins(8, 4, 8, 4); layout->setSpacing(10); // Icon QLabel *iconLabel = new QLabel(); QMimeDatabase mimeDb; QMimeType mime = mimeDb.mimeTypeForFileNameAndData(attachment.fileName, QByteArray()); QIcon icon = mimeIcon(mime.name()); iconLabel->setPixmap(icon.pixmap(24, 24)); iconLabel->setFixedSize(28, 28); layout->addWidget(iconLabel); // Name + size QVBoxLayout *textLayout = new QVBoxLayout(); textLayout->setSpacing(1); textLayout->setContentsMargins(0, 0, 0, 0); QLabel *nameLabel = new QLabel(attachment.fileName); nameLabel->setStyleSheet("font-weight: 500; font-size: 12px; color: #333;"); QLabel *sizeLabel = new QLabel(QString("%1 KB").arg(attachment.size / 1024)); sizeLabel->setStyleSheet("font-size: 10px; color: #888;"); textLayout->addWidget(nameLabel); textLayout->addWidget(sizeLabel); layout->addLayout(textLayout, 1); widget->setLayout(layout); listItem->setSizeHint(widget->sizeHint()); listItem->setData(Qt::UserRole, attachment.storedPath); listItem->setToolTip(QString("%1 (%2 KB)").arg(attachment.fileName).arg(attachment.size / 1024)); m_attachmentList->addItem(listItem); m_attachmentList->setItemWidget(listItem, widget); } m_attachmentsFrame->setVisible(!attachments.isEmpty()); // Body with sanitization + CSS QString cleanHtml = sanitizeHtml(item->bodyHtml()); m_bodyViewer->setHtml(cleanHtml); // Scroll to top m_bodyViewer->moveCursor(QTextCursor::Start); QScrollBar *vbar = m_scrollArea->verticalScrollBar(); if (vbar) vbar->setValue(0); } void ReaderView::refreshCurrentMail() { if (m_currentMailId < 0) return; std::optional item = MailItemDao::findById(m_currentMailId); if (!item.has_value()) { m_currentMailId = -1; setMailItem(nullptr); return; } // Re-apply without resetting zoom/state MailItem &mail = item.value(); m_subjectLabel->setText(mail.subject().isEmpty() ? "(Sin asunto)" : mail.subject()); QString sender = mail.sender(); QString avatarChar = "?"; QRegularExpression re("^\\s*(.*?)\\s*<[^>]+>\\s*$"); QRegularExpressionMatch match = re.match(sender); if (match.hasMatch()) { QString name = match.captured(1).trimmed(); if (!name.isEmpty()) avatarChar = name[0].toUpper(); } else if (!sender.isEmpty() && sender.contains("@")) { avatarChar = sender[0].toUpper(); } m_avatarLabel->setText(avatarChar); m_fromLabel->setText("De: " + formatAddress(sender)); m_dateLabel->setText("Fecha: " + formatDate(mail.date())); QString cleanHtml = sanitizeHtml(mail.bodyHtml()); m_bodyViewer->setHtml(cleanHtml); } void ReaderView::setDarkMode(bool dark) { m_darkMode = dark; QString bg = dark ? "#1e1e1e" : "white"; QString text = dark ? "#e0e0e0" : "#333"; QString border = dark ? "#333" : "#e0e0e0"; QString headerBg = dark ? "#252525" : "#fafafa"; QString toolbarBg = dark ? "#2a2a2a" : "#fafafa"; QString findBg = dark ? "#3e2723" : "#fff3cd"; QString findBorder = dark ? "#ffb74d" : "#ffc107"; m_headerWidget->setStyleSheet(QString("QWidget { background: %1; border-bottom: 1px solid %2; }").arg(headerBg).arg(border)); m_toolbar->setStyleSheet(QString("QFrame { background: %1; border-bottom: 1px solid %2; }").arg(toolbarBg).arg(border)); m_findBar->setStyleSheet(QString("QFrame { background: %1; border-bottom: 1px solid %2; }").arg(findBg).arg(findBorder)); m_bodyViewer->setStyleSheet(QString("QTextBrowser { background: transparent; border: none; font-family: 'Segoe UI', Helvetica, Arial, sans-serif; font-size: 13px; line-height: 1.5; color: %1; }").arg(text)); m_scrollArea->setStyleSheet(QString("QScrollArea { background: %1; border: none; }").arg(bg)); m_attachmentsFrame->setStyleSheet(QString("QFrame { background: %1; border-top: 1px solid %2; border-bottom: 1px solid %2; }").arg(headerBg).arg(border)); m_attachmentList->setStyleSheet(QString( "QListWidget { background: %1; border: 1px solid %2; border-radius: 6px; padding: 4px; }" "QListWidget::item { border: none; padding: 6px 8px; border-radius: 4px; color: %3; }" "QListWidget::item:hover { background: %4; }" "QListWidget::item:selected { background: %5; color: %6; }" ).arg(dark ? "#2a2a2a" : "white").arg(border).arg(text).arg(dark ? "#333" : "#f0f0f0").arg(dark ? "#1976D2" : "#e3f2fd").arg(dark ? "#90caf9" : "#1976D2")); m_subjectLabel->setStyleSheet(QString("color: %1;").arg(text)); m_fromLabel->setStyleSheet(QString("color: %1; font-size: 13px;").arg(text)); m_toLabel->setStyleSheet(QString("color: %1; font-size: 12px;").arg(dark ? "#aaa" : "#666")); m_dateLabel->setStyleSheet(QString("color: %1; font-size: 12px;").arg(dark ? "#888" : "#888")); m_attachmentsHeader->setStyleSheet(QString("color: %1;").arg(dark ? "#ccc" : "#555")); m_findCountLabel->setStyleSheet(QString("color: %1; font-size: 11px;").arg(dark ? "#ffb74d" : "#856404")); m_zoomLabel->setStyleSheet(QString("font-size: 11px; color: %1;").arg(dark ? "#aaa" : "#555")); // Update load images button for dark mode if (m_loadImagesButton) { if (dark) { m_loadImagesButton->setStyleSheet( "QToolButton {" " background: #1e3a5f;" " border: 1px solid #64b5f6;" " border-radius: 4px;" " padding: 4px 10px;" " font-size: 11px;" " color: #90caf9;" " font-weight: 500;" "}" "QToolButton:hover {" " background: #2a4a6f;" "}" ); } else { m_loadImagesButton->setStyleSheet( "QToolButton {" " background: #e3f2fd;" " border: 1px solid #1976D2;" " border-radius: 4px;" " padding: 4px 10px;" " font-size: 11px;" " color: #1976D2;" " font-weight: 500;" "}" "QToolButton:hover {" " background: #bbdefb;" "}" ); } } // Re-render body with new theme refreshCurrentMail(); } // Slots for action buttons void ReaderView::onReplyClicked() { if (m_currentMailId >= 0) emit replyRequested(m_currentMailId); } void ReaderView::onForwardClicked() { if (m_currentMailId >= 0) emit forwardRequested(m_currentMailId); } void ReaderView::onDeleteClicked() { if (m_currentMailId >= 0) emit deleteRequested(m_currentMailId); } void ReaderView::onDetachClicked() { emit detachRequested(); } ReaderView::~ReaderView() = default;