Files
wino-mail-dtkqt/src/ui/readerview.cpp
T
javier 6e4a588879 feat: move-to-folder dialog, batch actions, reader source view, Focused/Other pivot
- MoveToFolderDialog: modal folder picker per account (excludes current)
- Wire 'Mover a...' in reader ⋯ menu + batch move (emit -1 -> dialog)
- Connect batch delete/mark-unread/flag handlers (were unconnected)
- Reader: 'Ver codigo fuente' reads .eml via MimeStorageService::readEmlFile
- Reader: 'Marcar como no leido' emits markUnreadRequested
- Pivot Focused/Other: EmailListModel::setShowFocusedOnly heuristic
  (flagged/pinned/frequent senders), reset to Focused on inbox select
2026-08-30 02:26:31 +02:00

951 lines
40 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "ui/readerview.h"
#include "services/mimestorage.h"
#include <QFont>
#include <QDesktopServices>
#include <QUrl>
#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 <QDialog>
#include <QPlainTextEdit>
#include <QDialogButtonBox>
#include <QScrollBar>
#include <QShortcut>
#include <QFileDialog>
#include <QDir>
#include <functional>
#include <QGraphicsDropShadowEffect>
#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<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]() {
if (m_currentMailId < 0) return;
std::optional<MailItem> 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 "<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) {
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("<div class='email-body'><i style='color:#999'>(Seleccione un correo para leerlo)</i></div>");
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<StoredAttachmentRecord> 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<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();
}
ReaderView::~ReaderView() = default;