2026-06-17 22:44:27 +02:00
|
|
|
|
#include "ui/readerview.h"
|
|
|
|
|
|
#include <QFont>
|
2026-08-17 15:34:49 +02:00
|
|
|
|
#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>
|
2026-08-24 01:04:00 +02:00
|
|
|
|
#include <QGraphicsDropShadowEffect>
|
2026-08-17 15:34:49 +02:00
|
|
|
|
#include "db/dao/mailitemdao.h"
|
2026-06-17 22:44:27 +02:00
|
|
|
|
|
|
|
|
|
|
ReaderView::ReaderView(QWidget *parent) : QWidget(parent) {
|
|
|
|
|
|
setupUI();
|
2026-08-23 14:49:58 +02:00
|
|
|
|
applyEmailCss();
|
2026-06-17 22:44:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
void ReaderView::setupUI() {
|
|
|
|
|
|
QVBoxLayout *mainLayout = new QVBoxLayout(this);
|
2026-08-24 10:29:13 +02:00
|
|
|
|
mainLayout->setContentsMargins(10, 0, 10, 0);
|
2026-08-23 14:49:58 +02:00
|
|
|
|
mainLayout->setSpacing(0);
|
2026-06-17 22:44:27 +02:00
|
|
|
|
|
2026-08-24 10:29:13 +02:00
|
|
|
|
// ===== 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);
|
|
|
|
|
|
|
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-06-17 22:44:27 +02:00
|
|
|
|
|
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-17 22:44:27 +02:00
|
|
|
|
});
|
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);
|
|
|
|
|
|
|
2026-08-24 01:04:00 +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);
|
|
|
|
|
|
|
2026-08-24 01:04:00 +02:00
|
|
|
|
// 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));
|
|
|
|
|
|
});
|
2026-08-24 01:04:00 +02:00
|
|
|
|
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);
|
2026-08-24 01:04:00 +02:00
|
|
|
|
metaRightLayout->addWidget(m_toLabel);
|
2026-08-23 14:49:58 +02:00
|
|
|
|
|
|
|
|
|
|
m_dateLabel = new QLabel();
|
|
|
|
|
|
m_dateLabel->setStyleSheet("color: #888; font-size: 12px;");
|
2026-08-24 01:04:00 +02:00
|
|
|
|
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);
|
2026-08-24 01:04:00 +02:00
|
|
|
|
|
|
|
|
|
|
// ===== 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));
|
2026-06-17 22:44:27 +02:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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: —");
|
2026-08-17 15:34:49 +02:00
|
|
|
|
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>");
|
2026-06-17 22:44:27 +02:00
|
|
|
|
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
|
2026-08-17 15:34:49 +02:00
|
|
|
|
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());
|
2026-08-17 15:34:49 +02:00
|
|
|
|
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-17 15:34:49 +02:00
|
|
|
|
}
|
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();
|
2026-08-17 15:34:49 +02:00
|
|
|
|
}
|
2026-08-28 13:45:21 +02:00
|
|
|
|
ReaderView::~ReaderView() = default;
|