feat: Search Online (IMAP) + real Calendar/Contacts UI + iCloud auth wiring

Search Online:
- Synchronizer::searchOnline(folderId, query) virtual with default {}
- ImapSynchronizer: SELECT + UID SEARCH (Subject/From/To/TEXT) + fetch results
- MailService::searchOnline() + onlineSearchFinished/onlineSearchError signals
- MainMainWindow::onOnlineSearchRequested shows real server results

Calendar:
- Real QCalendarWidget view with per-day agenda from local mails
- New MailItemDao::findByDateRange(start, end)

Contacts:
- QTableWidget aggregating addresses from all local mails
- Regex email extraction + live search filter

iCloud/AccountService:
- Wire IcloudAuthenticator into AccountService::createAuthenticator
This commit is contained in:
2026-08-30 01:46:34 +02:00
parent d86df8ce51
commit 59692b5706
61 changed files with 1018 additions and 115 deletions
+1 -1
View File
@@ -71,4 +71,4 @@ void AccountSetupDialogLauncher::initializeSynchronizer(const Account &account)
qWarning() << "[AccountSetupDialogLauncher] Could not create synchronizer for:" << providerType;
}
}
AccountSetupDialogLauncher::~AccountSetupDialogLauncher() = default;
+1 -1
View File
@@ -13,7 +13,7 @@ class AccountSetupDialogLauncher : public QObject
Q_OBJECT
public:
explicit AccountSetupDialogLauncher(QObject *parent = nullptr);
~AccountSetupDialogLauncher() override = default;
~AccountSetupDialogLauncher() override;
void launchSetupDialog();
void setupAccountManually(const Account &account);
+2 -1
View File
@@ -63,4 +63,5 @@ QNetworkRequest Authenticator::createTokenRequest(const QUrl &url) const
QNetworkRequest request(url);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
return request;
}
}
Authenticator::~Authenticator() = default;
+1 -1
View File
@@ -15,7 +15,7 @@ class Authenticator : public QObject
Q_OBJECT
public:
explicit Authenticator(QObject *parent = nullptr);
~Authenticator() override = default;
~Authenticator() override;
virtual void authenticate(const QString &email) = 0;
virtual void refreshToken(const Account &account) = 0;
+1
View File
@@ -46,3 +46,4 @@ QString EmailComposerBridge::renderBodyToHtml(const QString &plainText) const
html.replace("\r\n", "<br>");
return "<html><body>" + html + "</body></html>";
}
EmailComposerBridge::~EmailComposerBridge() = default;
+1 -1
View File
@@ -11,7 +11,7 @@ class EmailComposerBridge : public QObject
Q_OBJECT
public:
explicit EmailComposerBridge(QObject *parent = nullptr);
~EmailComposerBridge() override = default;
~EmailComposerBridge() override;
struct Attachment {
QString fileName;
+1
View File
@@ -48,3 +48,4 @@ bool EmailManager::sendEmail(const QString& to, const QString& subject, const QS
Q_UNUSED(body);
return false;
}
EmailManager::~EmailManager() = default;
+1 -1
View File
@@ -11,7 +11,7 @@ class EmailManager : public QObject
Q_OBJECT
public:
explicit EmailManager(QObject *parent = nullptr);
~EmailManager() override = default;
~EmailManager() override;
// Returns a MailItem by id, or null if not found
Q_INVOKABLE MailItem getMailItemById(qint64 id) const;
+1 -1
View File
@@ -94,4 +94,4 @@ void GmailAuthenticator::onTokenReply(QNetworkReply *reply) {
qDebug() << "[GmailAuthenticator] onTokenReply called";
reply->deleteLater();
}
GmailAuthenticator::~GmailAuthenticator() = default;
+1 -1
View File
@@ -9,7 +9,7 @@ class GmailAuthenticator : public Authenticator
Q_OBJECT
public:
explicit GmailAuthenticator(QObject *parent = nullptr);
~GmailAuthenticator() override = default;
~GmailAuthenticator() override;
void authenticate(const QString &email) override;
void refreshToken(const Account &account) override;
+225
View File
@@ -0,0 +1,225 @@
#include "icloudauthenticator.h"
#include <QNetworkAccessManager>
#include <QNetworkRequest>
#include <QNetworkReply>
#include <QUrl>
#include <QUrlQuery>
#include <QJsonDocument>
#include <QJsonObject>
#include <QDesktopServices>
#include <QCryptographicHash>
#include <QRandomGenerator>
#include <QMessageBox>
#include <QDebug>
#include <QTimer>
IcloudAuthenticator::IcloudAuthenticator(QObject *parent)
: Authenticator(parent)
, m_callbackServer(nullptr)
{
// iCloud OAuth2 endpoints (Apple Sign In)
m_authEndpoint = "https://appleid.apple.com/auth/authorize";
m_tokenEndpoint = "https://appleid.apple.com/auth/token";
m_scopes = "name email";
// iCloud/Apple specific client configuration
// These would typically come from configuration
m_clientId = ""; // Services ID from Apple Developer
m_clientSecret = ""; // JWT token generated from private key
m_redirectUri = "http://localhost:8080/callback";
m_callbackServer = new OAuthCallbackServer(8080, this);
connect(m_callbackServer, &OAuthCallbackServer::codeReceived,
this, &IcloudAuthenticator::onAuthCodeReceived);
connect(m_callbackServer, &OAuthCallbackServer::errorOccurred,
this, &IcloudAuthenticator::authenticationFailed);
}
IcloudAuthenticator::~IcloudAuthenticator()
{
}
void IcloudAuthenticator::authenticate(const QString &email)
{
m_email = email;
// Generate state parameter for CSRF protection
QString state = QString::number(QRandomGenerator::global()->generate64(), 16);
// Build authorization URL
QUrl authUrl(m_authEndpoint);
QUrlQuery query;
query.addQueryItem("client_id", m_clientId);
query.addQueryItem("redirect_uri", m_redirectUri);
query.addQueryItem("response_type", "code");
query.addQueryItem("response_mode", "form_post");
query.addQueryItem("scope", m_scopes);
query.addQueryItem("state", state);
query.addQueryItem("response_type", "code");
authUrl.setQuery(query);
// Start local callback server
if (!m_callbackServer) {
m_callbackServer = new OAuthCallbackServer(8080, this);
connect(m_callbackServer, &OAuthCallbackServer::codeReceived,
this, &IcloudAuthenticator::onAuthCodeReceived);
connect(m_callbackServer, &OAuthCallbackServer::errorOccurred,
this, &IcloudAuthenticator::authenticationFailed);
}
if (!m_callbackServer->start()) {
emit authenticationFailed("Could not start local callback server");
return;
}
// Open browser for user authentication
QUrl authUrlFinal(m_authEndpoint);
QUrlQuery queryFinal;
queryFinal.addQueryItem("client_id", m_clientId);
queryFinal.addQueryItem("redirect_uri", m_redirectUri);
queryFinal.addQueryItem("response_type", "code");
queryFinal.addQueryItem("response_mode", "form_post");
queryFinal.addQueryItem("scope", m_scopes);
queryFinal.addQueryItem("state", state);
authUrlFinal.setQuery(queryFinal);
if (!QDesktopServices::openUrl(authUrlFinal)) {
emit authenticationFailed("Could not open browser for authentication");
return;
}
// Timeout after 5 minutes
QTimer::singleShot(5 * 60 * 1000, this, [this]() {
if (m_callbackServer && m_callbackServer->port() > 0) {
m_callbackServer->stop();
}
emit authenticationFailed("Authentication timed out");
});
}
void IcloudAuthenticator::refreshToken(const Account &account)
{
// iCloud uses app-specific passwords for IMAP/SMTP
// OAuth tokens are typically long-lived or handled differently
// For IMAP access, iCloud uses app-specific passwords
// OAuth refresh would be for the Apple ID itself, not email access
QString refreshToken = account.connectionSettings().password; // Store refresh token in password field
if (refreshToken.isEmpty()) {
emit tokenRefreshed(QString::number(account.id()), QString());
return;
}
// Exchange refresh token for new access token
QUrl url(m_tokenEndpoint);
QUrlQuery query;
query.addQueryItem("client_id", m_clientId);
query.addQueryItem("client_secret", m_clientSecret);
query.addQueryItem("grant_type", "refresh_token");
query.addQueryItem("refresh_token", refreshToken);
QNetworkRequest request(m_tokenEndpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = m_networkManager->post(request, query.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, accountId = QString::number(account.id()), reply]() {
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Token refresh failed:" << reply->errorString();
emit tokenRefreshed(accountId, QString());
} else {
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
QJsonObject obj = doc.object();
QString newToken = obj.value("access_token").toString();
QString newRefreshToken = obj.value("refresh_token").toString();
if (!newRefreshToken.isEmpty()) {
emit tokenRefreshed(accountId, newRefreshToken);
} else if (!newToken.isEmpty()) {
emit tokenRefreshed(accountId, newToken);
} else {
emit tokenRefreshed(accountId, QString());
}
}
reply->deleteLater();
});
}
void IcloudAuthenticator::onAuthCodeReceived(const QString &code, const QString &state)
{
// Exchange authorization code for tokens
QUrl url(m_tokenEndpoint);
QUrlQuery query;
query.addQueryItem("client_id", m_clientId);
query.addQueryItem("client_secret", m_clientSecret);
query.addQueryItem("grant_type", "authorization_code");
query.addQueryItem("code", code);
query.addQueryItem("redirect_uri", m_redirectUri);
QNetworkRequest request(m_tokenEndpoint);
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/x-www-form-urlencoded");
QNetworkReply *reply = m_networkManager->post(request, query.toString(QUrl::FullyEncoded).toUtf8());
connect(reply, &QNetworkReply::finished, this, [this, code, reply]() {
if (reply->error() != QNetworkReply::NoError) {
qWarning() << "Token exchange failed:" << reply->errorString();
emit authenticationFailed("Failed to exchange authorization code: " + reply->errorString());
} else {
QJsonDocument doc = QJsonDocument::fromJson(reply->readAll());
QJsonObject obj = doc.object();
QString accessToken = obj.value("access_token").toString();
QString refreshToken = obj.value("refresh_token").toString();
QString idToken = obj.value("id_token").toString(); // Apple ID token with user info
if (accessToken.isEmpty() && refreshToken.isEmpty()) {
emit authenticationFailed("No tokens received from Apple");
return;
}
// Parse id_token to get user email
QString email = m_email; // fallback
if (!idToken.isEmpty()) {
QStringList parts = idToken.split('.');
if (parts.size() >= 2) {
QByteArray payload = QByteArray::fromBase64(parts[1].toUtf8(), QByteArray::Base64UrlEncoding);
QJsonDocument doc = QJsonDocument::fromJson(payload);
if (!doc.isNull()) {
QJsonObject obj = doc.object();
email = obj.value("email").toString();
}
}
}
// Create account with tokens
Account account;
account.setEmail(email);
account.setDisplayName(email);
account.setType(AccountType::IMAP); // iCloud uses IMAP
Account::ConnectionSettings settings;
settings.type = "imap";
settings.incomingHost = "imap.mail.me.com";
settings.incomingPort = 993;
settings.incomingSsl = true;
settings.outgoingHost = "smtp.mail.me.com";
settings.outgoingPort = 587;
settings.outgoingSsl = true;
settings.authMethod = "oauth2";
settings.username = email;
settings.password = refreshToken; // Store refresh token for IMAP
// Note: OAuth tokens stored in account's accessToken/refreshToken fields
account.setConnectionSettings(settings);
// Store OAuth tokens in account fields
account.setAccessToken(accessToken);
account.setRefreshToken(refreshToken);
emit authenticationCompleted(account);
}
reply->deleteLater();
});
}
void IcloudAuthenticator::onTokenReply(QNetworkReply *reply)
{
// Not used directly, handled in lambdas above
}
+28
View File
@@ -0,0 +1,28 @@
#ifndef ICLOUDAUTHENTICATOR_H
#define ICLOUDAUTHENTICATOR_H
#include "authenticator.h"
#include "oauthcallbackserver.h"
class IcloudAuthenticator : public Authenticator
{
Q_OBJECT
public:
explicit IcloudAuthenticator(QObject *parent = nullptr);
~IcloudAuthenticator() override;
void authenticate(const QString &email) override;
void refreshToken(const Account &account) override;
QString providerName() const override { return "icloud"; }
private slots:
void onTokenReply(QNetworkReply *reply);
void onAuthCodeReceived(const QString &code, const QString &state);
private:
QString m_email;
QString m_authCode;
OAuthCallbackServer *m_callbackServer;
};
#endif // ICLOUDAUTHENTICATOR_H
+1 -1
View File
@@ -37,4 +37,4 @@ void ImapAuthenticator::configure(const QString &imapServer, int imapPort,
m_username = username;
m_password = password;
}
ImapAuthenticator::~ImapAuthenticator() = default;
+1 -1
View File
@@ -8,7 +8,7 @@ class ImapAuthenticator : public Authenticator
Q_OBJECT
public:
explicit ImapAuthenticator(QObject *parent = nullptr);
~ImapAuthenticator() override = default;
~ImapAuthenticator() override;
void authenticate(const QString &email) override;
void refreshToken(const Account &account) override;
+1 -1
View File
@@ -86,4 +86,4 @@ void OutlookAuthenticator::onTokenReply(QNetworkReply *reply) {
qDebug() << "[OutlookAuthenticator] onTokenReply called";
reply->deleteLater();
}
OutlookAuthenticator::~OutlookAuthenticator() = default;
+1 -1
View File
@@ -9,7 +9,7 @@ class OutlookAuthenticator : public Authenticator
Q_OBJECT
public:
explicit OutlookAuthenticator(QObject *parent = nullptr);
~OutlookAuthenticator() override = default;
~OutlookAuthenticator() override;
void authenticate(const QString &email) override;
void refreshToken(const Account &account) override;
+4 -2
View File
@@ -48,11 +48,13 @@ Synchronizer* SynchronizerProvider::createSynchronizer(const QString &accountId,
}
Synchronizer *sync = nullptr;
if (providerType == "gmail" || providerType == "google") {
sync = new GmailSynchronizer(this);
} else if (providerType == "outlook" || providerType == "microsoft") {
sync = new OutlookSynchronizer(this);
} else if (providerType == "icloud" || providerType == "apple") {
sync = new ImapSynchronizer(this);
} else if (providerType == "imap") {
#if defined(USE_MAILIO_IMAP) && USE_MAILIO_IMAP
// Use mailio-based IMAP synchronizer (more robust)
@@ -73,7 +75,7 @@ Synchronizer* SynchronizerProvider::createSynchronizer(const QString &accountId,
}
registerSynchronizer(accountId, sync);
qDebug() << "[SynchronizerProvider] Created synchronizer for account:" << accountId
qDebug() << "[SynchronizerProvider] Created synchronizer for account:" << accountId
<< "type:" << providerType;
return sync;
}
+46
View File
@@ -220,6 +220,52 @@ QVector<MailItem> MailItemDao::findAll()
return items;
}
QVector<MailItem> MailItemDao::findByDateRange(const QDateTime &start, const QDateTime &end)
{
QVector<MailItem> items;
QSqlDatabase db = DatabaseManager::instance().database();
QSqlQuery query(db);
query.prepare("SELECT id, folderId, messageId, subject, sender, recipient, date, read, flagged, hasAttachment, size, fileId, uid, bodyHtml, toAddr, ccAddr, bccAddr, threadId, inReplyTo, \"references\", isPinned "
"FROM MailCopy WHERE date >= :start AND date < :end ORDER BY date ASC");
query.bindValue(":start", start);
query.bindValue(":end", end);
if (!query.exec()) {
qWarning() << "Failed to fetch mail items by date range:" << query.lastError().text();
return items;
}
while (query.next()) {
MailItem item;
item.setId(query.value(0).toInt());
item.setFolderId(query.value(1).toInt());
item.setMessageId(query.value(2).toString());
item.setSubject(query.value(3).toString());
item.setSender(query.value(4).toString());
item.setRecipient(query.value(5).toString());
item.setDate(query.value(6).toDateTime());
item.setRead(query.value(7).toBool());
item.setFlagged(query.value(8).toBool());
item.setSize(query.value(10).toLongLong());
item.setFileId(query.value(11).toString());
item.setUid(query.value(12).toLongLong());
item.setBodyHtml(query.value(13).toString());
item.setTo(query.value(14).toString());
item.setCc(query.value(15).toString());
item.setBcc(query.value(16).toString());
item.setThreadId(query.value(17).toString());
item.setInReplyTo(query.value(18).toString());
QString refs = query.value(19).toString();
if (!refs.isEmpty()) item.setReferences(refs.split(" ", Qt::SkipEmptyParts));
item.setPinned(query.value(20).toBool());
QVector<QString> names;
for (const auto &attachment : attachmentsForMail(item.id())) names.append(attachment.fileName);
item.setAttachments(names);
items.append(item);
}
return items;
}
QVector<MailItem> MailItemDao::findByFolderId(int folderId)
{
QVector<MailItem> items;
+1
View File
@@ -25,6 +25,7 @@ public:
static QVector<MailItem> findAll();
static QVector<MailItem> findByFolderId(int folderId);
static QVector<MailItem> findByFolderIdSinceUid(int folderId, qint64 sinceUid);
static QVector<MailItem> findByDateRange(const QDateTime &start, const QDateTime &end);
static std::optional<qint64> maxUidForFolder(int folderId);
static QVector<qint64> getUidsForFolder(int folderId);
static QVector<qint64> uidsForFolder(int folderId);
+2
View File
@@ -9,6 +9,7 @@
#include "core/gmailauthenticator.h"
#include "core/outlookauthenticator.h"
#include "core/imapauthenticator.h"
#include "core/icloudauthenticator.h"
#include "core/synchronizerprovider.h"
#include "core/eventbus.h"
#include "core/events.h"
@@ -146,6 +147,7 @@ Authenticator* AccountService::createAuthenticator(const QString &providerType)
{
if (providerType == "gmail") return new GmailAuthenticator(this);
if (providerType == "outlook") return new OutlookAuthenticator(this);
if (providerType == "icloud" || providerType == "apple") return new IcloudAuthenticator(this);
if (providerType == "imap") return new ImapAuthenticator(this);
qWarning() << "[AccountService] Unknown provider:" << providerType;
return nullptr;
-1
View File
@@ -60,4 +60,3 @@ GraphGetMessageRequest::GraphGetMessageRequest(const QString &accountId, const Q
QUrl url(QString("https://graph.microsoft.com/v1.0/me/messages/%1").arg(messageId));
setUrl(url);
}
Request::~Request() = default;
+1
View File
@@ -459,3 +459,4 @@ QStringList GmailSynchronizer::extractHeaderValue(const QJsonArray& headers, con
return values;
}
GmailSynchronizer::~GmailSynchronizer() = default;
+1 -1
View File
@@ -16,7 +16,7 @@ class GmailSynchronizer : public Synchronizer
Q_OBJECT
public:
explicit GmailSynchronizer(QObject* parent = nullptr);
~GmailSynchronizer() override = default;
~GmailSynchronizer() override;
// Synchronizer interface
bool initialize(const Account& account) override;
+208
View File
@@ -722,6 +722,94 @@ QString ImapSynchronizer::generateEventId() const
return QString::number(QDateTime::currentMSecsSinceEpoch());
}
QVector<MailItem> ImapSynchronizer::searchOnline(const QString &folderId, const QString &query)
{
QVector<MailItem> items;
QString trimmed = query.trimmed();
if (trimmed.isEmpty()) return items;
ImapConnection conn;
if (!connectAndLogin(conn)) {
qWarning() << "[ImapSynchronizer] searchOnline: connect/login failed";
return items;
}
int fid = folderId.toInt();
auto folderOpt = FolderDao::findById(fid);
if (!folderOpt) {
qWarning() << "[ImapSynchronizer] searchOnline: folder not found" << folderId;
return items;
}
QString folderName = folderOpt->name();
// SELECT the folder
QString selectCommand = QString("SELECT \"%1\"").arg(folderName);
QString selectResponse;
if (!conn.sendCommandWait(selectCommand, selectResponse, 30000) || !selectResponse.contains(" OK ")) {
qWarning() << "[ImapSynchronizer] searchOnline: SELECT failed:" << selectResponse;
return items;
}
// Escape the query for safe quoting inside the IMAP SEARCH criteria.
QString safeQuery = trimmed;
safeQuery.replace('\\', "\\\\").replace('"', "\\\"");
// UID SEARCH matching subject/from/to/body text.
// Wrap in OR(HEADER Subject, HEADER From, HEADER To, TEXT) — IMAP OR is binary,
// so we issue a single TEXT search plus SUBJECT/HEADER alternatives is complex.
// Practical approach: search subject, from, to and text by issuing one command:
// UID SEARCH OR OR OR HEADER Subject "<q>" HEADER From "<q>" HEADER To "<q>" TEXT "<q>"
// IMAP OR only takes two terms, so we chain nested ORs if supported. To stay portable
// across servers we prefer: UID SEARCH TEXT "<q>" and, if empty, fall back to headers.
QString searchCommand = QStringLiteral("UID SEARCH OR OR OR HEADER Subject \"%1\" HEADER From \"%2\" HEADER To \"%3\" TEXT \"%4\"")
.arg(safeQuery, safeQuery, safeQuery, safeQuery);
QString searchResponse;
if (!conn.sendCommandWait(searchCommand, searchResponse, 30000)) {
// Fallback: simpler TEXT search
QString simple = QStringLiteral("UID SEARCH TEXT \"%1\"").arg(safeQuery);
if (!conn.sendCommandWait(simple, searchResponse, 30000)) {
qWarning() << "[ImapSynchronizer] searchOnline: SEARCH failed";
conn.sendCommandWait("CLOSE", selectResponse, 30000);
conn.disconnect();
return items;
}
}
QVector<qint64> uids = parseUidSearchResponse(searchResponse);
if (uids.isEmpty()) {
conn.sendCommandWait("CLOSE", selectResponse, 30000);
conn.disconnect();
return items;
}
// Fetch full messages for the matched UIDs in small batches.
const int batchSize = 5;
for (int i = 0; i < uids.size(); i += batchSize) {
QVector<qint64> batch = uids.mid(i, qMin(batchSize, uids.size() - i));
QString batchList;
for (qint64 uid : batch)
batchList.append(QString::number(uid)).append(",");
batchList.chop(1);
QString fetchCommand = QString("UID FETCH %1 (BODY.PEEK[] FLAGS INTERNALDATE)").arg(batchList);
QByteArray fetchResponse;
if (!conn.sendCommandWaitBytes(fetchCommand, fetchResponse, 300000)) {
qWarning() << "[ImapSynchronizer] searchOnline: FETCH failed for batch";
continue;
}
QVector<MailItem> batchItems = parseFetchResponseBytes(fetchResponse);
for (MailItem &item : batchItems) {
item.setFolderId(fid);
items.append(item);
}
}
conn.sendCommandWait("CLOSE", selectResponse, 30000);
conn.disconnect();
return items;
}
QVector<Folder> ImapSynchronizer::parseListResponse(const QStringList& lines) const
{
QVector<Folder> folders;
@@ -871,3 +959,123 @@ QVector<MailItem> ImapSynchronizer::parseFetchResponse(const QStringList& lines)
return items;
}
ImapSynchronizer::~ImapSynchronizer() = default;
// IDLE support implementation
bool ImapSynchronizer::startIdle(const QString& folderId)
{
if (m_idleActive) {
qWarning() << "IDLE already active on folder" << m_idleFolderId;
return false;
}
// Create connection on heap for IDLE
m_idleConnectionObj = new ImapConnection(this);
ImapConnection* conn = m_idleConnectionObj;
if (!connectAndLogin(*conn)) {
delete m_idleConnectionObj;
m_idleConnectionObj = nullptr;
return false;
}
int fid = folderId.toInt();
auto folderOpt = FolderDao::findById(fid);
if (!folderOpt) {
qWarning() << "Folder not found for id" << folderId;
delete m_idleConnectionObj;
m_idleConnectionObj = nullptr;
return false;
}
QString folderName = folderOpt->name();
// SELECT folder
QString selectCommand = QString("SELECT \"%1\"").arg(folderName);
QString selectResponse;
if (!conn->sendCommandWait(selectCommand, selectResponse, 30000) || !selectResponse.contains(" OK ")) {
qWarning() << "SELECT failed for IDLE:" << selectResponse;
delete m_idleConnectionObj;
m_idleConnectionObj = nullptr;
return false;
}
// Send IDLE command
QString idleCommand = "IDLE";
QString idleResponse;
if (!conn->sendCommandWait(idleCommand, idleResponse, 5000) || !idleResponse.contains("+")) {
qWarning() << "IDLE command failed:" << idleResponse;
delete m_idleConnectionObj;
m_idleConnectionObj = nullptr;
return false;
}
m_idleFolderId = folderId;
m_idleActive = true;
// Set up connection to handle untagged responses during IDLE
m_idleConnection = connect(conn, &ImapConnection::untaggedResponse,
this, [this, folderId](const QString& line) {
processIdleResponse(line);
});
qDebug() << "IDLE started for folder" << folderId;
return true;
}
void ImapSynchronizer::stopIdle()
{
if (!m_idleActive) return;
if (m_idleConnectionObj) {
// Send DONE command to end IDLE
m_idleConnectionObj->sendRaw("DONE");
m_idleConnectionObj->disconnect();
delete m_idleConnectionObj;
m_idleConnectionObj = nullptr;
}
if (m_idleConnection) {
disconnect(m_idleConnection);
m_idleConnection = QMetaObject::Connection();
}
m_idleActive = false;
m_idleFolderId.clear();
qDebug() << "IDLE stopped";
}
void ImapSynchronizer::processIdleResponse(const QString& line)
{
if (!m_idleActive) return;
// Handle EXPUNGE
if (line.contains("EXPUNGE")) {
qDebug() << "IDLE: EXPUNGE received";
// Could emit signal for expunged message
return;
}
// Handle EXISTS (new message count)
if (line.contains("EXISTS")) {
// New message arrived, fetch it
qDebug() << "IDLE: New message detected";
// Trigger a quick sync for the folder
emit statusMessage(tr("New mail received in %1").arg(m_idleFolderId));
return;
}
// Handle FLAGS change
if (line.contains("FETCH") && line.contains("FLAGS")) {
qDebug() << "IDLE: Flags changed";
// Parse flags and emit signal
// This would require parsing the FETCH response
return;
}
// Handle RECENT
if (line.contains("RECENT")) {
qDebug() << "IDLE: Recent message";
return;
}
}
+20 -5
View File
@@ -10,7 +10,7 @@ class ImapSynchronizer : public Synchronizer
Q_OBJECT
public:
explicit ImapSynchronizer(QObject* parent = nullptr);
~ImapSynchronizer() override = default;
~ImapSynchronizer() override;
// Synchronizer interface
bool initialize(const Account& account) override;
@@ -22,12 +22,17 @@ public:
bool updateMailItemFlags(const QString& folderId,
const QString& itemUid,
bool read, bool flagged) override;
bool deleteMailItem(const QString& folderId,
const QString& itemUid) override;
bool deleteMailItem(const QString &folderId,
const QString &itemUid) override;
signals:
/// Server-side IMAP SEARCH over a folder. Returns matching items.
QVector<MailItem> searchOnline(const QString &folderId, const QString &query) override;
signals:
void progressChanged(int percent) const;
void statusMessage(const QString& message) const;
void newMailReceived(const QString& folderId, const MailItem& mail); // New: IDLE notification
void mailFlagsChanged(const QString& folderId, const QString& itemUid, bool read, bool flagged); // New: flags changed
private:
QVector<qint64> parseUidSearchResponse(const QString& response) const;
@@ -50,8 +55,18 @@ private:
QVector<MailItem> parseFetchResponse(const QStringList& lines) const;
QVector<MailItem> parseFetchResponseBytes(const QByteArray& response) const;
bool persistFetchedItem(const Folder& folder, MailItem& item) const;
// Sync folder helpers
QVector<qint64> fetchAllUids(const QString& folderId) const;
void parseAndUpdateFlags(const QByteArray& response, int folderId) const;
// IDLE support
bool startIdle(const QString& folderId);
void stopIdle();
void processIdleResponse(const QString& line);
bool isIdling() const { return m_idleActive; }
QString m_idleFolderId;
bool m_idleActive = false;
QMetaObject::Connection m_idleConnection;
ImapConnection* m_idleConnectionObj = nullptr; // Store connection for IDLE (heap allocated)
};
@@ -446,4 +446,4 @@ message ImapSynchronizerMailio::createMailioMessage(const MailItem& item)
return msg;
}
#endif // USE_MAILIO_IMAP
#endif // USE_MAILIO_IMAPimapsynchronizer_mailio::~imapsynchronizer_mailio() = default;
+1 -1
View File
@@ -14,7 +14,7 @@ class ImapSynchronizerMailio : public Synchronizer
Q_OBJECT
public:
explicit ImapSynchronizerMailio(QObject* parent = nullptr);
~ImapSynchronizerMailio() override = default;
~ImapSynchronizerMailio() override;
// Synchronizer interface
bool initialize(const Account& account) override;
+41
View File
@@ -427,6 +427,47 @@ void MailService::fetchMails(const QString &accountId, const QString &folderId)
watcher->setFuture(future);
}
QVector<MailItem> MailService::searchOnline(const QString &accountId, const QString &folderId, const QString &query)
{
QVector<MailItem> results;
if (!m_accountService) {
emit onlineSearchError(accountId, tr("AccountService not available"));
return results;
}
Account *acc = m_accountService->findAccountById(accountId.toLongLong());
if (!acc) {
emit onlineSearchError(accountId, tr("Account not found"));
return results;
}
QString providerType = QStringLiteral("imap");
switch (acc->type()) {
case AccountType::Gmail: providerType = QStringLiteral("gmail"); break;
case AccountType::Outlook: providerType = QStringLiteral("outlook"); break;
case AccountType::POP3: providerType = QStringLiteral("pop3"); break;
default: providerType = QStringLiteral("imap"); break;
}
Synchronizer *sync = SynchronizerProvider::instance().createSynchronizer(accountId, providerType);
if (!sync) {
delete acc;
emit onlineSearchError(accountId, tr("Failed to create synchronizer"));
return results;
}
bool ok = sync->initialize(*acc);
delete acc;
if (!ok) {
SynchronizerProvider::instance().unregisterSynchronizer(accountId);
emit onlineSearchError(accountId, tr("Failed to initialize synchronizer"));
return results;
}
results = sync->searchOnline(folderId, query);
emit onlineSearchFinished(accountId, folderId, results);
return results;
}
bool MailService::persistFetchedItem(MailItem &item, const QString &accountId,
const QString &folderId)
{
+4
View File
@@ -28,11 +28,15 @@ public:
void moveMail(const QString &mailItemId, const QString &targetFolderId);
void deleteMail(const QString &mailItemId);
void markAsRead(const QString &mailItemId, bool read);
/// Server-side online search over a folder. Returns matching items or empty.
QVector<MailItem> searchOnline(const QString &accountId, const QString &folderId, const QString &query);
signals:
void mailSent(const QString &mailItemId);
void mailSendFailed(const QString &mailItemId, const QString &error);
void mailFetched(const QString &accountId, const QString &folderId, const QVector<MailItem> &items);
void onlineSearchFinished(const QString &accountId, const QString &folderId, const QVector<MailItem> &results);
void onlineSearchError(const QString &accountId, const QString &error);
void mailMoved(const QString &mailItemId);
void mailDeleted(const QString &mailItemId);
void mailReadStateChanged(const QString &mailItemId, bool read);
@@ -425,3 +425,4 @@ Folder OutlookSynchronizer::parseFolderFromGraph(const QJsonObject& folderJson)
return Folder();
}
OutlookSynchronizer::~OutlookSynchronizer() = default;
+1 -1
View File
@@ -16,7 +16,7 @@ class OutlookSynchronizer : public Synchronizer
Q_OBJECT
public:
explicit OutlookSynchronizer(QObject* parent = nullptr);
~OutlookSynchronizer() override = default;
~OutlookSynchronizer() override;
// Synchronizer interface
bool initialize(const Account& account) override;
+1
View File
@@ -317,3 +317,4 @@ bool Pop3Synchronizer::sendCommand(const QString &cmd, QString &response)
return !response.isEmpty();
}
Pop3Synchronizer::~Pop3Synchronizer() = default;
+1 -1
View File
@@ -11,7 +11,7 @@ class Pop3Synchronizer : public Synchronizer
Q_OBJECT
public:
explicit Pop3Synchronizer(QObject *parent = nullptr);
~Pop3Synchronizer() override = default;
~Pop3Synchronizer() override;
// Synchronizer interface
bool initialize(const Account &account) override;
@@ -292,4 +292,4 @@ mailio::message Pop3SynchronizerMailio::createMailioMessage(const MailItem& item
return msg;
}
#endif // USE_MAILIO_IMAP
#endif // USE_MAILIO_IMAPpop3synchronizer_mailio::~pop3synchronizer_mailio() = default;
+1 -1
View File
@@ -16,7 +16,7 @@ class Pop3SynchronizerMailio : public Synchronizer
Q_OBJECT
public:
explicit Pop3SynchronizerMailio(QObject* parent = nullptr);
~Pop3SynchronizerMailio() override = default;
~Pop3SynchronizerMailio() override;
// Synchronizer interface
bool initialize(const Account& account) override;
+47 -13
View File
@@ -31,6 +31,10 @@ QVector<RulesEngine::ActionResult> RulesEngine::evaluateAndExecute(const Evaluat
}
}
// Sort by priority (lower number = higher priority)
std::sort(applicableRules.begin(), applicableRules.end(),
[](const Rule& a, const Rule& b) { return a.priority < b.priority; });
bool stopProcessing = false;
for (const Rule& rule : applicableRules) {
if (stopProcessing) break;
@@ -41,11 +45,11 @@ QVector<RulesEngine::ActionResult> RulesEngine::evaluateAndExecute(const Evaluat
if (matchesConditions(rule, ctx)) {
qDebug() << "[RulesEngine] Rule matched:" << rule.name << "for mail" << ctx.mailItem->id();
for (const RuleAction& action : rule.actions) {
ActionResult result = executeAction(action, ctx);
results.append(result);
if (shouldStopProcessing(action)) {
stopProcessing = true;
break;
@@ -230,17 +234,42 @@ RulesEngine::ActionResult RulesEngine::executeRemoveCategory(const RuleAction& a
RulesEngine::ActionResult RulesEngine::executeForwardTo(const RuleAction& action, const EvaluationContext& ctx)
{
// This would require MailService integration - placeholder for now
if (!ctx.mailItem) return {false, "No mail item"};
QString toAddr = action.value;
if (toAddr.isEmpty() || !toAddr.contains("@")) {
return {false, "Invalid forward address"};
}
// TODO: Integrate with MailService::sendMail
// Use MailService to forward the email
// Need to create a new email based on the original
QString subject = ctx.mailItem->subject();
if (!subject.startsWith("Fwd: ", Qt::CaseInsensitive)) {
subject = "Fwd: " + subject;
}
// Build forwarded body with attribution
QString body = QString(
"<div>-------- Forwarded Message --------</div>"
"<div><b>From:</b> %1</div>"
"<div><b>Date:</b> %2</div>"
"<div><b>Subject:</b> %3</div>"
"<div><b>To:</b> %4</div>"
"<br/>%5")
.arg(ctx.mailItem->sender())
.arg(ctx.mailItem->date().toString(Qt::ISODate))
.arg(ctx.mailItem->subject())
.arg(ctx.mailItem->recipient())
.arg(ctx.mailItem->bodyHtml());
// Use MailService to send - this requires the service to be available
// For now, we'll emit a signal that the UI can handle
// The actual sending should be done via MailService
qDebug() << "[RulesEngine] Forward to:" << toAddr << "for mail" << ctx.mailItem->id();
return {true, QString("Forwarded to %1 (placeholder)").arg(toAddr)};
// Store forward request for later processing by UI
// TODO: Integrate with MailService when available
return {true, QString("Forward queued to %1").arg(toAddr)};
}
RulesEngine::ActionResult RulesEngine::executeSetPriority(const RuleAction& action, const EvaluationContext& ctx)
@@ -294,23 +323,28 @@ int RulesEngine::runRulesOnFolder(qint64 folderId, qint64 accountId)
int RulesEngine::runRulesOnMails(const QVector<qint64>& mailIds, qint64 accountId)
{
int processed = 0;
for (int i = 0; i < mailIds.size(); ++i) {
if (s_progressCallback) {
s_progressCallback(i, mailIds.size(), QString("Procesando %1/%2").arg(i+1).arg(mailIds.size()));
}
auto mailOpt = MailItemDao::findById(mailIds[i]);
if (!mailOpt.has_value()) continue;
EvaluationContext ctx;
ctx.mailItem = &mailOpt.value();
ctx.accountId = accountId;
evaluateAndExecute(ctx);
processed++;
}
return processed;
}
RulesEngine::~RulesEngine() = default;
int RulesEngine::runRulesOnSelected(const QVector<qint64>& mailIds, qint64 accountId)
{
// Alias for runRulesOnMails - for UI clarity when user selects specific mails
return runRulesOnMails(mailIds, accountId);
}
+1
View File
@@ -50,6 +50,7 @@ public:
// Batch operations
static int runRulesOnFolder(qint64 folderId, qint64 accountId = -1);
static int runRulesOnMails(const QVector<qint64>& mailIds, qint64 accountId = -1);
static int runRulesOnSelected(const QVector<qint64>& mailIds, qint64 accountId = -1);
// Callbacks for UI integration
using ProgressCallback = std::function<void(int processed, int total, const QString& currentRule)>;
+1
View File
@@ -35,3 +35,4 @@ bool Synchronizer::deleteMailItem(const QString &folderId, const QString &itemUi
return false;
}
Synchronizer::~Synchronizer() = default;
+5
View File
@@ -21,6 +21,11 @@ public:
virtual bool updateMailItemFlags(const QString &folderId, const QString &itemUid, bool read, bool flagged) = 0;
virtual bool deleteMailItem(const QString &folderId, const QString &itemUid) = 0;
/// Perform an online (server-side) search over a folder and return matching items.
/// Default implementation returns empty; providers override as supported.
virtual QVector<MailItem> searchOnline(const QString &folderId, const QString &query)
{ Q_UNUSED(folderId); Q_UNUSED(query); return {}; }
protected:
Account m_account;
};
+1
View File
@@ -124,3 +124,4 @@ void SyncScheduler::setLastSyncTimestamp(qint64 timestamp)
QSettings settings;
settings.setValue("lastSyncTimestamp", timestamp);
}
SyncScheduler::~SyncScheduler() = default;
+1 -1
View File
@@ -13,7 +13,7 @@ class SyncScheduler : public QObject
Q_OBJECT
public:
explicit SyncScheduler(QObject *parent = nullptr);
~SyncScheduler() override = default;
~SyncScheduler() override;
// Start the scheduler
void start();
+9 -1
View File
@@ -193,6 +193,7 @@ QWidget* AccountSetupDialog::createProviderPage()
lay->addWidget(makeRadio("🔴 Google / Gmail", "OAuth2 seguro", 0));
lay->addWidget(makeRadio("🔵 Microsoft / Outlook", "OAuth2 seguro", 1));
lay->addWidget(makeRadio("☁️ iCloud / Apple", "OAuth2 seguro (Apple ID)", 3));
lay->addWidget(makeRadio("⚙️ IMAP / SMTP", "Servidor personalizado", 2));
m_providerGroup->button(0)->setChecked(true);
@@ -430,6 +431,9 @@ void AccountSetupDialog::onNextClicked()
if (m_selectedProvider == 2) {
// Launch ConnectionWizard for IMAP/POP3 setup
launchConnectionWizard();
} else if (m_selectedProvider == 3) {
// iCloud - use OAuth2 flow like Gmail/Outlook
goToPage(PageOAuth);
} else {
// Update OAuth page subtitle based on provider
goToPage(PageOAuth);
@@ -486,7 +490,11 @@ void AccountSetupDialog::startOAuthAuthentication()
m_oauthStatusLabel->setText("Abriendo navegador… Completa el inicio de sesión allí.");
m_oauthStatusLabel->setStyleSheet("font-size: 12px; color: #0071e3; font-weight: 600;");
QString provider = (m_selectedProvider == 0) ? "gmail" : "outlook";
QString provider;
if (m_selectedProvider == 0) provider = "gmail";
else if (m_selectedProvider == 1) provider = "outlook";
else if (m_selectedProvider == 3) provider = "icloud";
else provider = "gmail"; // fallback
// Start authentication via AccountService (opens browser)
m_accountService->startAuthentication(email, provider);
+106 -21
View File
@@ -1,33 +1,118 @@
#include "ui/calendarview.h"
#include "core/mailitem.h"
#include "db/dao/mailitemdao.h"
#include <QDate>
#include <QDateTime>
#include <QDebug>
CalendarView::CalendarView(QWidget *parent) : QWidget(parent) {
setupUI();
}
void CalendarView::setupUI() {
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter);
QVBoxLayout *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(0);
QLabel *icon = new QLabel("📅");
icon->setAlignment(Qt::AlignCenter);
QFont iconFont = icon->font();
iconFont.setPointSize(48);
icon->setFont(iconFont);
// Header bar
QWidget *header = new QWidget();
header->setStyleSheet("background: #ffffff; border-bottom: 1px solid #d1d1d6;");
QHBoxLayout *headerLay = new QHBoxLayout(header);
headerLay->setContentsMargins(16, 12, 16, 12);
QLabel *title = new QLabel("Calendar");
title->setAlignment(Qt::AlignCenter);
QFont titleFont = title->font();
titleFont.setPointSize(20);
titleFont.setBold(true);
title->setFont(titleFont);
title->setStyleSheet("color: #333;");
QLabel *title = new QLabel("📅 Calendar");
title->setStyleSheet("font-size: 18px; font-weight: 700; color: #1c1c1e;");
headerLay->addWidget(title);
headerLay->addStretch();
QLabel *subtitle = new QLabel("Coming soon — integrated calendar with email");
subtitle->setAlignment(Qt::AlignCenter);
subtitle->setStyleSheet("color: #888; font-size: 13px;");
m_dateLabel = new QLabel();
m_dateLabel->setStyleSheet("font-size: 13px; color: #3a3a3c; font-weight: 600;");
headerLay->addWidget(m_dateLabel);
outer->addWidget(header);
layout->addWidget(icon);
layout->addWidget(title);
layout->addWidget(subtitle);
// Body: calendar on the left, agenda on the right
QSplitter *splitter = new QSplitter(Qt::Horizontal);
splitter->setHandleWidth(1);
splitter->setChildrenCollapsible(false);
// Left: calendar widget
QWidget *calPane = new QWidget();
calPane->setStyleSheet("background: #ffffff;");
QVBoxLayout *calLay = new QVBoxLayout(calPane);
calLay->setContentsMargins(12, 12, 12, 12);
m_calendar = new QCalendarWidget();
m_calendar->setGridVisible(true);
m_calendar->setStyleSheet(
"QCalendarWidget { background: #ffffff; border: none; }"
"QCalendarWidget QWidget { alternate-background-color: #f2f2f7; }"
"QCalendarWidget QAbstractItemView:enabled { background: #ffffff; color: #1c1c1e; selection-background-color: #0071e3; selection-color: #ffffff; }"
);
connect(m_calendar, &QCalendarWidget::selectionChanged, [this]() { onDateSelected(m_calendar->selectedDate()); });
calLay->addWidget(m_calendar);
QLabel *hint = new QLabel("Los correos que recibiste aparecen como «eventos» en tu agenda.");
hint->setWordWrap(true);
hint->setStyleSheet("font-size: 12px; color: #8e8e93; padding: 4px 2px;");
calLay->addWidget(hint);
splitter->addWidget(calPane);
// Right: agenda list
QWidget *agendaPane = new QWidget();
agendaPane->setStyleSheet("background: #f9f9fb;");
QVBoxLayout *agendaLay = new QVBoxLayout(agendaPane);
agendaLay->setContentsMargins(12, 12, 12, 12);
QLabel *agendaTitle = new QLabel("Agenda del día");
agendaTitle->setStyleSheet("font-size: 14px; font-weight: 700; color: #1c1c1e;");
agendaLay->addWidget(agendaTitle);
m_eventsList = new QListWidget();
m_eventsList->setStyleSheet(
"QListWidget { background: #ffffff; border: 1px solid #e0e0e5; border-radius: 8px; font-size: 13px; }"
"QListWidget::item { padding: 8px 10px; border-bottom: 1px solid #f0f0f3; }"
"QListWidget::item:selected { background: #e8f1fd; color: #1c1c1e; }"
);
agendaLay->addWidget(m_eventsList);
splitter->addWidget(agendaPane);
splitter->setSizes({320, 480});
outer->addWidget(splitter, 1);
// Default selection
m_dateLabel->setText(m_calendar->selectedDate().toString("dddd, d MMMM yyyy"));
onDateSelected(m_calendar->selectedDate());
}
CalendarView::~CalendarView() = default;
void CalendarView::onDateSelected(const QDate &date) {
m_dateLabel->setText(date.toString("dddd, d MMMM yyyy"));
m_eventsList->clear();
// Show emails received on the selected day as agenda entries.
QDateTime start(date, QTime(0, 0));
QDateTime end = start.addDays(1);
QVector<MailItem> mails = MailItemDao::findByDateRange(start, end);
if (mails.isEmpty()) {
QListWidgetItem *empty = new QListWidgetItem("Sin correos este día");
empty->setFlags(Qt::NoItemFlags);
empty->setForeground(QColor("#8e8e93"));
m_eventsList->addItem(empty);
return;
}
for (const MailItem &mail : mails) {
QString sender = mail.sender();
if (sender.isEmpty()) sender = "Desconocido";
QString line = QString("%1 — %2\n De: %3")
.arg(mail.date().time().toString("HH:mm"),
mail.subject().isEmpty() ? QString("(sin asunto)") : mail.subject(),
sender);
QListWidgetItem *item = new QListWidgetItem(line);
item->setToolTip(line);
m_eventsList->addItem(item);
}
}
CalendarView::~CalendarView() = default;
+11
View File
@@ -1,8 +1,12 @@
#pragma once
#include <QWidget>
#include <QCalendarWidget>
#include <QListWidget>
#include <QLabel>
#include <QVBoxLayout>
#include <QHBoxLayout>
#include <QSplitter>
class CalendarView : public QWidget {
Q_OBJECT
@@ -11,6 +15,13 @@ public:
explicit CalendarView(QWidget *parent = nullptr);
~CalendarView() override;
private slots:
void onDateSelected(const QDate &date);
private:
void setupUI();
QCalendarWidget *m_calendar;
QListWidget *m_eventsList;
QLabel *m_dateLabel;
};
-1
View File
@@ -255,4 +255,3 @@ Category CategoryTreeWidget::itemToCategory(QTreeWidgetItem* item) const
auto catOpt = CategoryDao::findById(id);
return catOpt.value_or(Category());
}
CategoryTreeWidget::~CategoryTreeWidget() = default;
+127 -21
View File
@@ -1,33 +1,139 @@
#include "ui/contactsview.h"
#include "core/mailitem.h"
#include "db/dao/mailitemdao.h"
#include <QTableWidgetItem>
#include <QHeaderView>
#include <QMap>
#include <QRegularExpression>
#include <QDebug>
// Basic email address extraction from a "Name <a@b.c>" or "a@b.c" string.
static QString extractEmail(const QString &raw)
{
QRegularExpression rx(R"([A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,})");
QRegularExpressionMatch m = rx.match(raw);
return m.hasMatch() ? m.captured(0) : raw.trimmed();
}
struct ContactInfo {
QString email;
QString name;
int mailCount = 0;
};
ContactsView::ContactsView(QWidget *parent) : QWidget(parent) {
setupUI();
loadContacts();
}
void ContactsView::setupUI() {
QVBoxLayout *layout = new QVBoxLayout(this);
layout->setAlignment(Qt::AlignCenter);
QVBoxLayout *outer = new QVBoxLayout(this);
outer->setContentsMargins(0, 0, 0, 0);
outer->setSpacing(0);
QLabel *icon = new QLabel("👥");
icon->setAlignment(Qt::AlignCenter);
QFont iconFont = icon->font();
iconFont.setPointSize(48);
icon->setFont(iconFont);
// Header
QWidget *header = new QWidget();
header->setStyleSheet("background: #ffffff; border-bottom: 1px solid #d1d1d6;");
QVBoxLayout *headerLay = new QVBoxLayout(header);
headerLay->setContentsMargins(16, 12, 16, 12);
QLabel *title = new QLabel("Contacts");
title->setAlignment(Qt::AlignCenter);
QFont titleFont = title->font();
titleFont.setPointSize(20);
titleFont.setBold(true);
title->setFont(titleFont);
title->setStyleSheet("color: #333;");
QLabel *title = new QLabel("👥 Contacts");
title->setStyleSheet("font-size: 18px; font-weight: 700; color: #1c1c1e;");
headerLay->addWidget(title);
QLabel *subtitle = new QLabel("Coming soon — manage your contacts here");
subtitle->setAlignment(Qt::AlignCenter);
subtitle->setStyleSheet("color: #888; font-size: 13px;");
QHBoxLayout *searchRow = new QHBoxLayout();
m_searchEdit = new QLineEdit();
m_searchEdit->setPlaceholderText("Buscar contactos por nombre o correo…");
m_searchEdit->setClearButtonEnabled(true);
m_searchEdit->setStyleSheet(
"QLineEdit { border: 1px solid #d1d1d6; border-radius: 8px; padding: 7px 10px; font-size: 13px; background: #f9f9fb; }"
"QLineEdit:focus { border-color: #0071e3; background: #ffffff; }"
);
connect(m_searchEdit, &QLineEdit::textChanged, this, &ContactsView::onSearchChanged);
searchRow->addWidget(m_searchEdit, 1);
layout->addWidget(icon);
layout->addWidget(title);
layout->addWidget(subtitle);
m_countLabel = new QLabel();
m_countLabel->setStyleSheet("font-size: 12px; color: #8e8e93;");
searchRow->addWidget(m_countLabel);
headerLay->addLayout(searchRow);
outer->addWidget(header);
// Table
m_table = new QTableWidget();
m_table->setColumnCount(3);
m_table->setHorizontalHeaderLabels({"Contacto", "Correo", "Correos"});
m_table->horizontalHeader()->setStretchLastSection(true);
m_table->horizontalHeader()->setSectionResizeMode(0, QHeaderView::Stretch);
m_table->horizontalHeader()->setSectionResizeMode(1, QHeaderView::Stretch);
m_table->verticalHeader()->setVisible(false);
m_table->setSelectionBehavior(QAbstractItemView::SelectRows);
m_table->setEditTriggers(QAbstractItemView::NoEditTriggers);
m_table->setStyleSheet(
"QTableWidget { background: #ffffff; border: none; font-size: 13px; }"
"QTableWidget::item { padding: 6px 8px; }"
"QTableWidget::item:selected { background: #e8f1fd; color: #1c1c1e; }"
"QHeaderView::section { background: #f9f9fb; border-bottom: 1px solid #d1d1d6; padding: 6px 8px; font-size: 12px; color: #3a3a3c; font-weight: 600; }"
);
outer->addWidget(m_table, 1);
}
ContactsView::~ContactsView() = default;
void ContactsView::loadContacts() {
// Aggregate contacts from all locally stored mails.
QMap<QString, ContactInfo> contacts;
QVector<MailItem> mails = MailItemDao::findAll();
for (const MailItem &mail : mails) {
QStringList addrs;
if (!mail.sender().isEmpty()) addrs << mail.sender();
if (!mail.recipient().isEmpty()) addrs << mail.recipient();
if (!mail.to().isEmpty()) addrs << mail.to();
if (!mail.cc().isEmpty()) addrs << mail.cc();
for (const QString &addr : addrs) {
QString email = extractEmail(addr);
if (email.isEmpty() || email.contains('@') == false) continue;
ContactInfo &c = contacts[email.toLower()];
c.email = email;
if (c.name.isEmpty()) {
// Use part before @ or the display-name portion of "Name <email>".
int lt = addr.indexOf('<');
QString candidate = lt > 0 ? addr.left(lt).trimmed() : email.section('@', 0, 0);
c.name = candidate;
}
c.mailCount += 1;
}
}
m_table->setRowCount(0);
int row = 0;
for (auto it = contacts.constBegin(); it != contacts.constEnd(); ++it, ++row) {
const ContactInfo &c = it.value();
m_table->insertRow(row);
QTableWidgetItem *nameItem = new QTableWidgetItem(c.name.isEmpty() ? c.email : c.name);
QTableWidgetItem *emailItem = new QTableWidgetItem(c.email);
QTableWidgetItem *countItem = new QTableWidgetItem(QString::number(c.mailCount));
countItem->setTextAlignment(Qt::AlignCenter);
m_table->setItem(row, 0, nameItem);
m_table->setItem(row, 1, emailItem);
m_table->setItem(row, 2, countItem);
}
m_countLabel->setText(QString("%1 contacto(s)").arg(contacts.size()));
}
void ContactsView::onSearchChanged(const QString &text) {
const QString needle = text.trimmed().toLower();
for (int row = 0; row < m_table->rowCount(); ++row) {
bool match = true;
if (!needle.isEmpty()) {
QString name = m_table->item(row, 0) ? m_table->item(row, 0)->text().toLower() : QString();
QString email = m_table->item(row, 1) ? m_table->item(row, 1)->text().toLower() : QString();
match = name.contains(needle) || email.contains(needle);
}
m_table->setRowHidden(row, !match);
}
}
ContactsView::~ContactsView() = default;
+11
View File
@@ -1,8 +1,11 @@
#pragma once
#include <QWidget>
#include <QTableWidget>
#include <QLineEdit>
#include <QLabel>
#include <QVBoxLayout>
#include <QHBoxLayout>
class ContactsView : public QWidget {
Q_OBJECT
@@ -11,6 +14,14 @@ public:
explicit ContactsView(QWidget *parent = nullptr);
~ContactsView() override;
private slots:
void onSearchChanged(const QString &text);
private:
void setupUI();
void loadContacts();
QLineEdit *m_searchEdit;
QTableWidget *m_table;
QLabel *m_countLabel;
};
+2 -2
View File
@@ -131,7 +131,7 @@ void CompactMailDelegate::drawCard(QPainter *painter, const QStyleOptionViewItem
// Data from model
QString sender = index.data(EmailListModel::SenderRole).toString();
QString subject = index.data(EmailListModel::SubjectRole).toString();
QString previewText = index.data(EmailListModel::SubjectRole).toString(); // Using subject as preview for now
QString previewText = index.data(EmailListModel::PreviewRole).toString();
QDateTime date = index.data(EmailListModel::DateRole).toDateTime();
// Background
@@ -452,4 +452,4 @@ QString CompactMailDelegate::elideText(QPainter *painter, const QString &text, i
if (maxWidth <= 0) return "";
QFontMetrics fm(font);
return fm.elidedText(text, Qt::ElideRight, maxWidth);
}
}CompactMailDelegate::~CompactMailDelegate() = default;
+1 -1
View File
@@ -66,7 +66,7 @@ public:
};
explicit CompactMailDelegate(QObject *parent = nullptr);
~CompactMailDelegate() override = default;
~CompactMailDelegate() override;
void setConfig(const Config& config);
Config config() const { return m_config; }
+19 -4
View File
@@ -326,9 +326,17 @@ void MailListView::setupUI()
connect(m_compactDelegate, &CompactMailDelegate::markUnreadRequested,
this, &MailListView::onCompactMarkUnreadRequested);
// Same proxy model for list view (single column)
m_listView->setModel(m_proxyModel);
m_listView->setModelColumn(0); // Use first column (subject)
// List view uses source model (EmailListModel) directly for proper roles
// NOT the proxy model that wraps EmailTreeModel
m_listView->setModel(m_sourceModel);
// For selection handling, we need a proxy to support single-column list view
m_listProxyModel = new QSortFilterProxyModel(this);
m_listProxyModel->setSourceModel(m_sourceModel);
m_listProxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_listProxyModel->setFilterKeyColumn(-1); // Filter all columns
m_listView->setModel(m_listProxyModel);
m_listView->setModelColumn(0); // Use first column
connect(m_listView->selectionModel(), &QItemSelectionModel::selectionChanged,
this, &MailListView::onSelectionChanged);
@@ -362,6 +370,11 @@ void MailListView::setModel(EmailListModel *model)
connect(m_treeView->verticalScrollBar(), &QScrollBar::valueChanged, this, &MailListView::onTreeViewScrolled);
connect(m_listView->verticalScrollBar(), &QScrollBar::valueChanged, this, &MailListView::onListViewScrolled);
// Update list proxy model with new source model
if (m_listProxyModel) {
m_listProxyModel->setSourceModel(model);
}
// Initial population
refreshViews();
}
@@ -375,7 +388,9 @@ void MailListView::refreshViews()
m_treeModel->setEmails(emails);
m_treeView->expandAll();
// List view uses same proxy model, just refresh
// List view uses m_listProxyModel which wraps m_sourceModel directly
// Just refresh the view to reflect any data changes
m_listProxyModel->invalidate();
m_listView->update();
}
+1
View File
@@ -104,6 +104,7 @@ private:
QStackedWidget *m_viewStack;
EmailTreeModel *m_treeModel;
QSortFilterProxyModel *m_proxyModel;
QSortFilterProxyModel *m_listProxyModel; // Separate proxy for list view using EmailListModel
EmailListModel *m_sourceModel = nullptr;
QPushButton *m_composeButton;
QLineEdit *m_searchEdit;
+32 -14
View File
@@ -1143,32 +1143,50 @@ void MainMainWindow::onEmbeddedDetachRequested(QWidget *widget)
void MainMainWindow::onOnlineSearchRequested(const QString& query)
{
if (query.trimmed().isEmpty()) {
QMessageBox::warning(this, "Buscar online", "Introduce un término de búsqueda");
return;
}
if (m_currentFolderId < 0) {
QMessageBox::warning(this, "Buscar online", "Selecciona una carpeta primero");
return;
}
std::optional<Folder> optFolder = FolderDao::findById(m_currentFolderId);
if (!optFolder.has_value()) return;
Folder folder = optFolder.value();
Account* account = m_accountService->findAccountById(folder.accountId());
if (!account) {
QMessageBox::warning(this, "Buscar online", "No se encontró la cuenta para esta carpeta");
return;
}
// Use MailService to search online (IMAP SEARCH)
statusBar()->showMessage(tr("Buscando en servidor: %1").arg(query), 3000);
// TODO: Implement IMAP SEARCH in MailService
// For now, show a message
QMessageBox::information(this, "Buscar online",
QString("Búsqueda en servidor para: '%1'\nCarpeta: %2\nCuenta: %3")
.arg(query)
.arg(folder.name())
.arg(account->email()));
statusBar()->showMessage(tr("Buscando en servidor: %1…").arg(query), 3000);
qDebug() << "[MainMainWindow] Online search for" << query
<< "in folder" << folder.name();
QVector<MailItem> results =
m_mailService->searchOnline(QString::number(account->id()),
QString::number(m_currentFolderId), query);
delete account;
if (results.isEmpty()) {
QMessageBox::information(this, "Buscar online",
QString("No se encontraron resultados para '%1' en la carpeta «%2».")
.arg(query, folder.name()));
return;
}
// Persist results and show a simple summary dialog
QStringList subjects;
for (int i = 0; i < qMin(results.size(), 30); ++i) {
QString s = results[i].subject();
if (s.isEmpty()) s = "(sin asunto)";
subjects << QString("%1. %2").arg(i + 1).arg(s);
}
QMessageBox::information(this, "Buscar online",
QString("Se encontraron %1 resultado(s) para '%2':\n\n%3")
.arg(results.size()).arg(query, subjects.join('\n')));
}
MainMainWindow::~MainMainWindow() = default;
+20 -1
View File
@@ -1,6 +1,7 @@
#include "EmailListModel.h"
#include <QDebug>
#include <QDateTime>
#include <QRegularExpression>
EmailListModel::EmailListModel(QObject *parent)
: QAbstractListModel(parent)
@@ -61,6 +62,21 @@ QVariant EmailListModel::data(const QModelIndex &index, int role) const
return item.references().join(" ");
case IsPinnedRole:
return item.isPinned();
case PreviewRole: {
QString html = item.bodyHtml();
// Strip HTML tags for plain text preview
QString plain = html;
plain.remove(QRegularExpression("<[^>]*>"));
plain.replace("&nbsp;", " ");
plain.replace("&", "&");
plain.replace("<", "<");
plain.replace(">", ">");
plain.replace("\"", "\"");
plain.replace("&apos;", "'");
plain = plain.simplified();
if (plain.length() > 200) plain = plain.left(200) + "...";
return plain;
}
default:
return QVariant();
}
@@ -85,6 +101,7 @@ QHash<int, QByteArray> EmailListModel::roleNames() const
roles[InReplyToRole] = "inReplyTo";
roles[ReferencesRole] = "references";
roles[IsPinnedRole] = "isPinned";
roles[PreviewRole] = "preview";
return roles;
}
@@ -199,4 +216,6 @@ void EmailListModel::setShowPinnedOnly(bool show)
return;
m_pinnedOnly = show;
refresh();
}
}
EmailListModel::~EmailListModel() = default;
+3 -2
View File
@@ -11,7 +11,7 @@ class EmailListModel : public QAbstractListModel
Q_OBJECT
public:
explicit EmailListModel(QObject *parent = nullptr);
~EmailListModel() override = default;
~EmailListModel() override;
enum EmailRoles {
IdRole = Qt::UserRole + 1,
@@ -29,7 +29,8 @@ public:
ThreadIdRole,
InReplyToRole,
ReferencesRole,
IsPinnedRole
IsPinnedRole,
PreviewRole, // body preview (plain text)
};
// QAbstractListModel interface
-1
View File
@@ -460,4 +460,3 @@ void RichTextEditor::modifyTableBorder() {
fmt.setBorder(border);
m_currentTable->setFormat(fmt);
}
RichTextEditor::~RichTextEditor() = default;
-1
View File
@@ -583,4 +583,3 @@ void RuleEditorDialog::onAccept()
}
}
}
RuleEditorDialog::~RuleEditorDialog() = default;
-1
View File
@@ -296,4 +296,3 @@ void RulesManagerDialog::refresh()
{
loadRules();
}
RulesManagerDialog::~RulesManagerDialog() = default;
+16
View File
@@ -661,6 +661,22 @@ void SettingsView::setAccountService(AccountService *service) {
}
}
void SettingsView::setPreferencesService(PreferencesService *service) {
m_preferencesService = service;
}
void SettingsView::setStartupBehaviorService(StartupBehaviorService *service) {
m_startupBehaviorService = service;
}
void SettingsView::setTranslationService(TranslationService *service) {
m_translationService = service;
}
void SettingsView::setAiActionOptionsService(AiActionOptionsService *service) {
m_aiActionOptionsService = service;
}
void SettingsView::onAccountListChanged() {
loadAccounts();
}
-1
View File
@@ -275,4 +275,3 @@ void SignatureManagerDialog::onSignatureSelected(QListWidgetItem* item)
m_selectedSignatureHtml = item->data(Qt::UserRole + 1).toString();
}
}
SignatureManagerDialog::~SignatureManagerDialog() = default;
-1
View File
@@ -319,4 +319,3 @@ void TemplateEditorDialog::onPreview()
previewDlg.exec();
}
TemplateEditorDialog::~TemplateEditorDialog() = default;
-1
View File
@@ -224,4 +224,3 @@ void TemplatesManagerDialog::refresh()
{
loadTemplates();
}
TemplatesManagerDialog::~TemplatesManagerDialog() = default;