Files
wino-mail-dtkqt/src/ui/readerview.cpp
T

905 lines
39 KiB
C++
Raw Normal View History

#include "ui/readerview.h"
#include <QFont>
#include <QDesktopServices>
#include <QUrl>
2026-08-23 14:49:58 +02:00
#include <QDebug>
#include <QMimeDatabase>
#include <QMimeType>
#include <QFileInfo>
#include <QMenu>
#include <QApplication>
#include <QStyle>
#include <QTextDocument>
#include <QTextCursor>
#include <QRegularExpression>
#include <QDateTime>
#include <QClipboard>
#include <QMessageBox>
#include <QScrollBar>
#include <QShortcut>
#include <QFileDialog>
#include <QDir>
#include <functional>
#include <QGraphicsDropShadowEffect>
#include "db/dao/mailitemdao.h"
ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
setupUI();
2026-08-23 14:49:58 +02:00
applyEmailCss();
}
void ReaderView::setupUI() {
QVBoxLayout *mainLayout = new QVBoxLayout(this);
mainLayout->setContentsMargins(12, 0, 12, 0);
2026-08-23 14:49:58 +02:00
mainLayout->setSpacing(0);
2026-08-23 14:49:58 +02:00
// ===== 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);
2026-08-23 14:49:58 +02:00
// 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();
});
2026-06-18 18:58:27 +02:00
2026-08-23 14:49:58 +02:00
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);
// ===== 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: 10px;"
"}"
);
// Add shadow effect
QGraphicsDropShadowEffect *shadowEffect = new QGraphicsDropShadowEffect(this);
shadowEffect->setBlurRadius(8);
shadowEffect->setOffset(0, 3);
shadowEffect->setColor(QColor(0, 0, 0, 40));
subjectFrame->setGraphicsEffect(shadowEffect);
QVBoxLayout *subjectLayout = new QVBoxLayout(subjectFrame);
subjectLayout->setContentsMargins(16, 16, 16, 16);
subjectLayout->setSpacing(8);
2026-08-23 14:49:58 +02:00
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(16);
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);
2026-08-23 14:49:58 +02:00
// ===== 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
2026-08-23 14:49:58 +02:00
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);
2026-08-23 14:49:58 +02:00
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);
2026-08-23 14:49:58 +02:00
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);
2026-08-23 14:49:58 +02:00
m_dateLabel = new QLabel();
m_dateLabel->setStyleSheet("color: #888; font-size: 12px;");
metaRightLayout->addWidget(m_dateLabel);
metaRow->addLayout(metaRightLayout, 1);
2026-08-23 14:49:58 +02:00
headerLayout->addLayout(metaRow);
// Actions row
QHBoxLayout *actionsLayout = new QHBoxLayout();
actionsLayout->setSpacing(4);
auto createActionButton = [&](const QString &text, const QString &tooltip, std::function<void()> 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<MailItem> 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<MailItem> item = MailItemDao::findById(m_currentMailId);
if (item.has_value()) QApplication::clipboard()->setText(item->sender());
}
});
moreMenu->addAction("Ver código fuente", [this]() { /* TODO */ });
moreMenu->addSeparator();
moreMenu->addAction("Marcar como no leído", [this]() { /* TODO */ });
moreMenu->addAction("Marcar como spam", [this]() { /* TODO */ });
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);
2026-08-23 14:49:58 +02:00
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 "<div class='email-body'><i style='color:#999'>(Mensaje vacío)</i></div>";
QString result = html;
// Wrap in email wrapper if not already structured
if (!result.contains("<body", Qt::CaseInsensitive) && !result.contains("<div class=\"email", Qt::CaseInsensitive)) {
result = QString("<div class=\"email-wrapper\"><div class=\"email-body\">%1</div></div>").arg(result);
}
// Security: Remove scripts, iframes, event handlers
result.remove(QRegularExpression("(?is)<script[^>]*>.*?</script>"));
result.remove(QRegularExpression("(?is)<iframe[^>]*>.*?</iframe>"));
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)<img([^>]*src\\s*=\\s*[\"'](?:https?|data):[^\"']*[\"'][^>]*)>");
int beforeCount = result.count("data-blocked=\"true\"");
result.replace(extImgRegex,
"<img\\1 style=\"max-width:100%;height:auto;\" data-blocked=\"true\" title=\"Imagen externa bloqueada por privacidad\">");
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("<body", "<body class=\"dark\"");
result.replace("<div class=\"email-wrapper\"", "<div class=\"email-wrapper\"><body class=\"dark\">");
result.replace("</div></div>", "</body></div></div>");
}
// Inject base CSS
QString styleTag = QString("<style>%1</style>").arg(m_baseCss);
if (result.contains("<head>", Qt::CaseInsensitive)) {
result.replace(QRegularExpression("(?i)</head>"), styleTag + "</head>");
} else if (result.contains("<html", Qt::CaseInsensitive)) {
result.replace(QRegularExpression("(?i)<html[^>]*>"), QString("<html>%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 <email@domain>" 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("<a href=\"mailto:%1\" style=\"color:#1976D2;text-decoration:none;\">%2</a> <%1>").arg(email, name);
}
return QString("<a href=\"mailto:%1\" style=\"color:#1976D2;text-decoration:none;\">%1</a>").arg(email);
}
// Just an email
if (raw.contains("@")) {
return QString("<a href=\"mailto:%1\" style=\"color:#1976D2;text-decoration:none;\">%1</a>").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) {
2026-08-23 14:49:58 +02:00
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();
2026-08-23 14:49:58 +02:00
m_attachmentsFrame->setVisible(false);
m_bodyViewer->setHtml("<div class='email-body'><i style='color:#999'>(Seleccione un correo para leerlo)</i></div>");
return;
}
2026-08-23 14:49:58 +02:00
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<StoredAttachmentRecord> attachments = MailItemDao::attachmentsForMail(item->id());
for (const StoredAttachmentRecord &attachment : attachments) {
2026-08-23 14:49:58 +02:00
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);
2026-08-23 14:49:58 +02:00
listItem->setToolTip(QString("%1 (%2 KB)").arg(attachment.fileName).arg(attachment.size / 1024));
m_attachmentList->addItem(listItem);
m_attachmentList->setItemWidget(listItem, widget);
}
2026-08-23 14:49:58 +02:00
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<MailItem> 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();
}