feat: IMAP sync incremental + Account Setup Wizard + robust FETCH parser
- ImapSynchronizer::syncFolder(): detecta eliminados (UIDs locales no en servidor), fetch nuevos (sinceUid), actualiza flags (FLAGS batch) - fetchAllUids(): SELECT + SEARCH ALL para lista completa UIDs servidor - parseAndUpdateFlags(): FETCH FLAGS en lotes 100, update DB si cambió read/flagged - AccountSetupDialog: integra ConnectionWizard para IMAP/POP3 con test de conexión real - IMAP FETCH parser robusto: logging respuesta servidor + fallback UID-by-UID - Fix: FETCH failed logging muestra first/last UID + respuesta truncada
This commit is contained in:
+389
-11
@@ -3,6 +3,302 @@
|
||||
#include <QFutureWatcher>
|
||||
#include <QtConcurrent/QtConcurrent>
|
||||
#include <QDebug>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMimeDatabase>
|
||||
#include <QRegularExpression>
|
||||
#include <QDateTime>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QEventLoop>
|
||||
#include <QUrl>
|
||||
#include "services/mimestorage.h"
|
||||
|
||||
namespace {
|
||||
|
||||
QByteArray smtpHeader(const QString &name, const QString &value)
|
||||
{
|
||||
const QByteArray utf8 = value.toUtf8();
|
||||
bool ascii = true;
|
||||
for (char c : utf8) {
|
||||
if (static_cast<unsigned char>(c) < 32 || static_cast<unsigned char>(c) > 126) {
|
||||
ascii = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const QByteArray encoded = ascii
|
||||
? utf8
|
||||
: QByteArray("=?UTF-8?B?") + utf8.toBase64() + QByteArray("?=");
|
||||
return name.toUtf8() + QByteArray(": ") + encoded + QByteArray("\r\n");
|
||||
}
|
||||
|
||||
QByteArray base64Lines(const QByteArray &data)
|
||||
{
|
||||
const QByteArray encoded = data.toBase64();
|
||||
QByteArray wrapped;
|
||||
for (int i = 0; i < encoded.size(); i += 76)
|
||||
wrapped.append(encoded.mid(i, 76)).append("\r\n");
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
QStringList smtpRecipients(const QString &value)
|
||||
{
|
||||
QStringList result;
|
||||
for (QString part : value.split(QRegularExpression(QStringLiteral("[,;]")), Qt::SkipEmptyParts)) {
|
||||
const QRegularExpressionMatch match = QRegularExpression(QStringLiteral("<([^>]+)>")).match(part);
|
||||
if (match.hasMatch()) part = match.captured(1);
|
||||
part = part.trimmed();
|
||||
if (!part.isEmpty()) result.append(part);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool smtpResponse(QSslSocket &socket, int expectedCode)
|
||||
{
|
||||
QByteArray line;
|
||||
while (true) {
|
||||
if (!socket.canReadLine() && !socket.waitForReadyRead(15000)) return false;
|
||||
line = socket.readLine();
|
||||
if (line.size() < 3) continue;
|
||||
bool ok = false;
|
||||
const int code = line.left(3).toInt(&ok);
|
||||
if (!ok) continue;
|
||||
// A space marks the final line of a multiline SMTP response.
|
||||
if (line.size() > 3 && line.at(3) == '-') continue;
|
||||
return code == expectedCode;
|
||||
}
|
||||
}
|
||||
|
||||
QByteArray composeMimeMessage(const MailItem &mail, const Account &account,
|
||||
const QStringList &attachmentPaths, QString &error)
|
||||
{
|
||||
QStringList recipients = smtpRecipients(mail.to());
|
||||
if (recipients.isEmpty()) recipients = smtpRecipients(mail.recipient());
|
||||
recipients.append(smtpRecipients(mail.cc()));
|
||||
recipients.append(smtpRecipients(mail.bcc()));
|
||||
if (recipients.isEmpty()) {
|
||||
error = QObject::tr("No recipients specified");
|
||||
return {};
|
||||
}
|
||||
recipients.removeDuplicates();
|
||||
|
||||
QByteArray message;
|
||||
const QString from = mail.sender().isEmpty() ? account.email() : mail.sender();
|
||||
message.append(smtpHeader(QStringLiteral("From"), from));
|
||||
message.append(smtpHeader(QStringLiteral("To"), mail.to().isEmpty() ? mail.recipient() : mail.to()));
|
||||
if (!mail.cc().isEmpty()) message.append(smtpHeader(QStringLiteral("Cc"), mail.cc()));
|
||||
message.append(smtpHeader(QStringLiteral("Subject"), mail.subject()));
|
||||
message.append("Date: ").append((mail.date().isValid() ? mail.date() : QDateTime::currentDateTimeUtc()).toString(Qt::RFC2822Date).toUtf8()).append("\r\n");
|
||||
message.append("MIME-Version: 1.0\r\n");
|
||||
|
||||
QByteArray body = mail.bodyHtml().toUtf8();
|
||||
body.replace("\r\n", "\n");
|
||||
body.replace('\r', '\n');
|
||||
body.replace('\n', "\r\n");
|
||||
if (attachmentPaths.isEmpty()) {
|
||||
message.append("Content-Type: text/html; charset=UTF-8\r\n");
|
||||
message.append("Content-Transfer-Encoding: 8bit\r\n\r\n");
|
||||
message.append(body).append("\r\n");
|
||||
return message;
|
||||
}
|
||||
|
||||
const QByteArray boundary = QByteArray("----WinoMail-")
|
||||
+ QByteArray::number(QDateTime::currentMSecsSinceEpoch());
|
||||
message.append("Content-Type: multipart/mixed; boundary=\"").append(boundary).append("\"\r\n\r\n");
|
||||
message.append("--").append(boundary).append("\r\n");
|
||||
message.append("Content-Type: text/html; charset=UTF-8\r\n");
|
||||
message.append("Content-Transfer-Encoding: 8bit\r\n\r\n").append(body).append("\r\n");
|
||||
|
||||
QMimeDatabase mimeDatabase;
|
||||
for (const QString &path : attachmentPaths) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
error = QObject::tr("Could not read attachment: %1").arg(path);
|
||||
return {};
|
||||
}
|
||||
const QFileInfo info(path);
|
||||
const QString fileName = info.fileName().replace('"', '_');
|
||||
const QString mimeType = mimeDatabase.mimeTypeForFile(info).name();
|
||||
message.append("--").append(boundary).append("\r\n");
|
||||
message.append("Content-Type: ").append(mimeType.toUtf8()).append("; name=\"").append(fileName.toUtf8()).append("\"\r\n");
|
||||
message.append("Content-Transfer-Encoding: base64\r\n");
|
||||
message.append("Content-Disposition: attachment; filename=\"").append(fileName.toUtf8()).append("\"\r\n\r\n");
|
||||
message.append(base64Lines(file.readAll()));
|
||||
}
|
||||
message.append("--").append(boundary).append("--\r\n");
|
||||
return message;
|
||||
}
|
||||
|
||||
bool sendSmtp(const MailItem &mail, const Account &account,
|
||||
const QStringList &attachmentPaths, QString &error)
|
||||
{
|
||||
const Account::ConnectionSettings settings = account.connectionSettings();
|
||||
if (settings.outgoingHost.isEmpty() || settings.outgoingPort == 0) {
|
||||
error = QObject::tr("SMTP server is not configured");
|
||||
return false;
|
||||
}
|
||||
const QByteArray mime = composeMimeMessage(mail, account, attachmentPaths, error);
|
||||
if (mime.isEmpty()) return false;
|
||||
QSslSocket socket;
|
||||
if (settings.outgoingSsl && settings.outgoingPort == 465)
|
||||
socket.connectToHostEncrypted(settings.outgoingHost, settings.outgoingPort);
|
||||
else
|
||||
socket.connectToHost(settings.outgoingHost, settings.outgoingPort);
|
||||
if (!socket.waitForConnected(15000)) { error = socket.errorString(); return false; }
|
||||
if (settings.outgoingSsl && settings.outgoingPort == 465) {
|
||||
if (!socket.waitForEncrypted(15000)) { error = socket.errorString(); return false; }
|
||||
}
|
||||
if (!smtpResponse(socket, 220)) { error = QObject::tr("SMTP greeting failed"); return false; }
|
||||
|
||||
auto command = [&](const QByteArray &value, int code) {
|
||||
socket.write(value + QByteArray("\r\n"));
|
||||
socket.flush();
|
||||
return smtpResponse(socket, code);
|
||||
};
|
||||
if (!command("EHLO localhost", 250)) { error = QObject::tr("SMTP EHLO failed"); return false; }
|
||||
if (settings.outgoingSsl && settings.outgoingPort != 465) {
|
||||
if (!command("STARTTLS", 220)) { error = QObject::tr("SMTP STARTTLS failed"); return false; }
|
||||
socket.startClientEncryption();
|
||||
if (!socket.waitForEncrypted(15000)) { error = socket.errorString(); return false; }
|
||||
if (!command("EHLO localhost", 250)) { error = QObject::tr("SMTP EHLO after TLS failed"); return false; }
|
||||
}
|
||||
const QString username = settings.username.isEmpty() ? account.email() : settings.username;
|
||||
if (!username.isEmpty()) {
|
||||
if (!command("AUTH LOGIN", 334)
|
||||
|| !command(username.toUtf8().toBase64(), 334)
|
||||
|| !command(settings.password.toUtf8().toBase64(), 235)) {
|
||||
error = QObject::tr("SMTP authentication failed");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
const QString from = mail.sender().isEmpty() ? account.email() : mail.sender();
|
||||
if (!command("MAIL FROM:<" + smtpRecipients(from).value(0).toUtf8() + ">", 250)) { error = QObject::tr("SMTP MAIL FROM failed"); return false; }
|
||||
const QStringList recipients = smtpRecipients(mail.to()) + smtpRecipients(mail.recipient())
|
||||
+ smtpRecipients(mail.cc()) + smtpRecipients(mail.bcc());
|
||||
QStringList uniqueRecipients = recipients;
|
||||
uniqueRecipients.removeDuplicates();
|
||||
for (const QString &recipient : uniqueRecipients) {
|
||||
if (!command("RCPT TO:<" + recipient.toUtf8() + ">", 250)) { error = QObject::tr("SMTP recipient rejected: %1").arg(recipient); return false; }
|
||||
}
|
||||
if (!command("DATA", 354)) { error = QObject::tr("SMTP DATA failed"); return false; }
|
||||
QByteArray stuffed;
|
||||
const QList<QByteArray> lines = mime.split('\n');
|
||||
for (QByteArray line : lines) {
|
||||
if (line.startsWith('.')) stuffed.append('.');
|
||||
stuffed.append(line);
|
||||
if (!line.endsWith('\r')) stuffed.append('\r');
|
||||
stuffed.append('\n');
|
||||
}
|
||||
socket.write(stuffed);
|
||||
socket.write(".\r\n");
|
||||
socket.flush();
|
||||
if (!smtpResponse(socket, 250)) { error = QObject::tr("SMTP message rejected"); return false; }
|
||||
command("QUIT", 221);
|
||||
return true;
|
||||
}
|
||||
|
||||
QByteArray base64Url(const QByteArray &data)
|
||||
{
|
||||
QByteArray result = data.toBase64();
|
||||
result.replace('+', '-');
|
||||
result.replace('/', '_');
|
||||
while (result.endsWith('=')) result.chop(1);
|
||||
return result;
|
||||
}
|
||||
|
||||
bool postJson(const QUrl &url, const QByteArray &token, const QJsonObject &payload,
|
||||
int expectedStatus, QString &error)
|
||||
{
|
||||
QNetworkAccessManager network;
|
||||
QNetworkRequest request(url);
|
||||
request.setRawHeader("Authorization", QByteArrayLiteral("Bearer ") + token);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QStringLiteral("application/json"));
|
||||
QNetworkReply *reply = network.post(request, QJsonDocument(payload).toJson(QJsonDocument::Compact));
|
||||
QEventLoop loop;
|
||||
QObject::connect(reply, &QNetworkReply::finished, &loop, &QEventLoop::quit);
|
||||
loop.exec();
|
||||
const int status = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
const bool ok = reply->error() == QNetworkReply::NoError && status == expectedStatus;
|
||||
if (!ok) error = reply->error() == QNetworkReply::NoError
|
||||
? QObject::tr("Server returned HTTP %1").arg(status) : reply->errorString();
|
||||
reply->deleteLater();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool sendGmailApi(const MailItem &mail, const Account &account,
|
||||
const QStringList &attachmentPaths, QString &error)
|
||||
{
|
||||
if (!account.isTokenValid()) {
|
||||
error = QObject::tr("Gmail access token is missing or expired");
|
||||
return false;
|
||||
}
|
||||
const QByteArray mime = composeMimeMessage(mail, account, attachmentPaths, error);
|
||||
if (mime.isEmpty()) return false;
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("raw"), QString::fromLatin1(base64Url(mime)));
|
||||
const QString user = QString::fromUtf8(QUrl::toPercentEncoding(account.email()));
|
||||
const QUrl url(QStringLiteral("https://gmail.googleapis.com/gmail/v1/users/%1/messages/send").arg(user));
|
||||
return postJson(url, account.accessToken().toUtf8(), payload, 200, error);
|
||||
}
|
||||
|
||||
QJsonArray graphRecipients(const QString &value)
|
||||
{
|
||||
QJsonArray result;
|
||||
for (const QString &address : smtpRecipients(value)) {
|
||||
QJsonObject recipient;
|
||||
QJsonObject email;
|
||||
email.insert(QStringLiteral("address"), address);
|
||||
recipient.insert(QStringLiteral("emailAddress"), email);
|
||||
result.append(recipient);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
bool sendOutlookApi(const MailItem &mail, const Account &account,
|
||||
const QStringList &attachmentPaths, QString &error)
|
||||
{
|
||||
if (!account.isTokenValid()) {
|
||||
error = QObject::tr("Outlook access token is missing or expired");
|
||||
return false;
|
||||
}
|
||||
QJsonObject message;
|
||||
message.insert(QStringLiteral("subject"), mail.subject());
|
||||
QJsonObject body;
|
||||
body.insert(QStringLiteral("contentType"), QStringLiteral("HTML"));
|
||||
body.insert(QStringLiteral("content"), mail.bodyHtml());
|
||||
message.insert(QStringLiteral("body"), body);
|
||||
message.insert(QStringLiteral("toRecipients"), graphRecipients(mail.to().isEmpty() ? mail.recipient() : mail.to()));
|
||||
message.insert(QStringLiteral("ccRecipients"), graphRecipients(mail.cc()));
|
||||
message.insert(QStringLiteral("bccRecipients"), graphRecipients(mail.bcc()));
|
||||
QJsonArray attachments;
|
||||
QMimeDatabase mimeDatabase;
|
||||
for (const QString &path : attachmentPaths) {
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly)) {
|
||||
error = QObject::tr("Could not read attachment: %1").arg(path);
|
||||
return false;
|
||||
}
|
||||
const QFileInfo info(path);
|
||||
QJsonObject attachment;
|
||||
attachment.insert(QStringLiteral("@odata.type"), QStringLiteral("#microsoft.graph.fileAttachment"));
|
||||
attachment.insert(QStringLiteral("name"), info.fileName());
|
||||
attachment.insert(QStringLiteral("contentType"), mimeDatabase.mimeTypeForFile(info).name());
|
||||
attachment.insert(QStringLiteral("contentBytes"), QString::fromLatin1(file.readAll().toBase64()));
|
||||
attachments.append(attachment);
|
||||
}
|
||||
message.insert(QStringLiteral("attachments"), attachments);
|
||||
QJsonObject payload;
|
||||
payload.insert(QStringLiteral("message"), message);
|
||||
payload.insert(QStringLiteral("saveToSentItems"), true);
|
||||
const QUrl url(QStringLiteral("https://graph.microsoft.com/v1.0/me/sendMail"));
|
||||
return postJson(url, account.accessToken().toUtf8(), payload, 202, error);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MailService::MailService(AccountService *accountService, QObject *parent)
|
||||
: QObject(parent)
|
||||
@@ -18,13 +314,43 @@ QVector<MailItem> MailService::getMails(const QString &folderId)
|
||||
|
||||
void MailService::sendMail(const MailItem &mail, const QString &accountId)
|
||||
{
|
||||
// For now we just simulate sending by using EmailComposerBridge
|
||||
// In a real implementation, this would use SMTP settings from the account.
|
||||
Q_UNUSED(mail);
|
||||
Q_UNUSED(accountId);
|
||||
// Emit success immediately; actual sending would be asynchronous.
|
||||
QMetaObject::invokeMethod(this, "mailSent", Qt::QueuedConnection,
|
||||
Q_ARG(QString, QString()));
|
||||
sendMail(mail, accountId, {});
|
||||
}
|
||||
|
||||
void MailService::sendMail(const MailItem &mail, const QString &accountId,
|
||||
const QStringList &attachmentPaths)
|
||||
{
|
||||
if (!m_accountService) {
|
||||
emit mailSendFailed(accountId, tr("AccountService not available"));
|
||||
return;
|
||||
}
|
||||
Account *account = m_accountService->findAccountById(accountId.toLongLong());
|
||||
if (!account) {
|
||||
emit mailSendFailed(accountId, tr("Account not found"));
|
||||
return;
|
||||
}
|
||||
const Account accountCopy = *account;
|
||||
delete account;
|
||||
QFuture<bool> future = QtConcurrent::run([mail, accountCopy, attachmentPaths]() {
|
||||
QString error;
|
||||
bool ok = false;
|
||||
if (accountCopy.type() == AccountType::Gmail)
|
||||
ok = sendGmailApi(mail, accountCopy, attachmentPaths, error);
|
||||
else if (accountCopy.type() == AccountType::Outlook)
|
||||
ok = sendOutlookApi(mail, accountCopy, attachmentPaths, error);
|
||||
else
|
||||
ok = sendSmtp(mail, accountCopy, attachmentPaths, error);
|
||||
if (!ok) qWarning() << "Send failed:" << error;
|
||||
return ok;
|
||||
});
|
||||
auto *watcher = new QFutureWatcher<bool>(this);
|
||||
connect(watcher, &QFutureWatcher<bool>::finished, this, [this, watcher, accountId]() {
|
||||
const bool ok = watcher->result();
|
||||
watcher->deleteLater();
|
||||
if (ok) emit mailSent(accountId);
|
||||
else emit mailSendFailed(accountId, tr("Mail server rejected the message"));
|
||||
});
|
||||
watcher->setFuture(future);
|
||||
}
|
||||
|
||||
void MailService::fetchMails(const QString &accountId, const QString &folderId)
|
||||
@@ -35,6 +361,10 @@ void MailService::fetchMails(const QString &accountId, const QString &folderId)
|
||||
return;
|
||||
}
|
||||
Account *acc = m_accountService->findAccountById(accountId.toLongLong());
|
||||
if (!acc) {
|
||||
emit mailFetchError(accountId, folderId, tr("Account not found"));
|
||||
return;
|
||||
}
|
||||
QString providerType = QStringLiteral("imap"); // fallback
|
||||
if (acc) {
|
||||
switch (acc->type()) {
|
||||
@@ -65,10 +395,12 @@ void MailService::fetchMails(const QString &accountId, const QString &folderId)
|
||||
|
||||
// Initialize synchronizer with account details before using it
|
||||
if (!sync->initialize(*acc)) {
|
||||
delete acc;
|
||||
emit mailFetchError(accountId, folderId, tr("Failed to initialize synchronizer"));
|
||||
sync->deleteLater();
|
||||
SynchronizerProvider::instance().unregisterSynchronizer(accountId);
|
||||
return;
|
||||
}
|
||||
delete acc;
|
||||
|
||||
// Connect progress signals (if they exist) using string-based syntax for compatibility
|
||||
QObject::connect(sync, SIGNAL(progressChanged(int)), this, SIGNAL(progressChanged(int)), Qt::QueuedConnection);
|
||||
@@ -85,12 +417,52 @@ void MailService::fetchMails(const QString &accountId, const QString &folderId)
|
||||
QObject::disconnect(sync, SIGNAL(progressChanged(int)), this, SIGNAL(progressChanged(int)));
|
||||
QObject::disconnect(sync, SIGNAL(statusMessage(const QString&)), this, SIGNAL(statusMessage(const QString&)));
|
||||
QVector<MailItem> items = watcher->result();
|
||||
QVector<MailItem> persisted;
|
||||
for (MailItem &item : items) {
|
||||
if (persistFetchedItem(item, accountId, folderId)) persisted.append(item);
|
||||
}
|
||||
watcher->deleteLater();
|
||||
emit mailFetched(accountId, folderId, items);
|
||||
emit mailFetched(accountId, folderId, persisted);
|
||||
});
|
||||
watcher->setFuture(future);
|
||||
}
|
||||
|
||||
bool MailService::persistFetchedItem(MailItem &item, const QString &accountId,
|
||||
const QString &folderId)
|
||||
{
|
||||
if (item.rawMime().isEmpty()) return MailItemDao::upsert(item);
|
||||
|
||||
MimeStorageService storage;
|
||||
ParsedMimeMessage parsed;
|
||||
QVector<QString> paths;
|
||||
if (!storage.parseMessage(item.rawMime(), parsed)
|
||||
|| !storage.storeMessage(accountId, folderId, item, item.rawMime(), &paths)) {
|
||||
qWarning() << "Failed to persist MIME message" << item.messageId();
|
||||
return false;
|
||||
}
|
||||
if (!MailItemDao::upsert(item)) {
|
||||
storage.deleteEmlFile(item.fileId());
|
||||
return false;
|
||||
}
|
||||
|
||||
QVector<StoredAttachmentRecord> records;
|
||||
for (int i = 0; i < parsed.attachments.size(); ++i) {
|
||||
const ParsedMimeAttachment &source = parsed.attachments.at(i);
|
||||
StoredAttachmentRecord record;
|
||||
record.fileName = source.fileName;
|
||||
record.mimeType = source.mimeType;
|
||||
record.contentId = source.contentId;
|
||||
record.size = source.data.size();
|
||||
if (i < paths.size()) record.storedPath = paths.at(i);
|
||||
records.append(record);
|
||||
}
|
||||
if (!MailItemDao::replaceAttachments(item.id(), records)) {
|
||||
storage.deleteEmlFile(item.fileId());
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
void MailService::moveMail(const QString &mailItemId, const QString &targetFolderId)
|
||||
{
|
||||
bool ok;
|
||||
@@ -109,8 +481,14 @@ void MailService::deleteMail(const QString &mailItemId)
|
||||
bool ok;
|
||||
qint64 id = mailItemId.toLongLong(&ok);
|
||||
if (!ok) return;
|
||||
if (MailItemDao::remove(id))
|
||||
const std::optional<MailItem> item = MailItemDao::findById(id);
|
||||
if (MailItemDao::remove(id)) {
|
||||
if (item && !item->fileId().isEmpty()) {
|
||||
MimeStorageService storage;
|
||||
storage.deleteEmlFile(item->fileId());
|
||||
}
|
||||
emit mailDeleted(mailItemId);
|
||||
}
|
||||
}
|
||||
|
||||
void MailService::markAsRead(const QString &mailItemId, bool read)
|
||||
@@ -126,4 +504,4 @@ void MailService::markAsRead(const QString &mailItemId, bool read)
|
||||
emit mailReadStateChanged(mailItemId, read);
|
||||
}
|
||||
|
||||
#include "mailservice.moc"
|
||||
#include "mailservice.moc"
|
||||
|
||||
Reference in New Issue
Block a user