Files
wino-mail-dtkqt/src/services/mailservice.cpp
T

508 lines
20 KiB
C++
Raw Normal View History

#include "mailservice.h"
#include <QFuture>
#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)
, m_composer(new EmailComposerBridge(this))
, m_accountService(accountService)
{
}
QVector<MailItem> MailService::getMails(const QString &folderId)
{
return MailItemDao::findByFolderId(folderId.toInt());
}
void MailService::sendMail(const MailItem &mail, const QString &accountId)
{
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)
{
// Determine provider type from account
if (!m_accountService) {
emit mailFetchError(accountId, folderId, tr("AccountService not available"));
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()) {
case AccountType::IMAP:
providerType = QStringLiteral("imap");
break;
case AccountType::POP3:
providerType = QStringLiteral("pop3");
break;
case AccountType::Gmail:
providerType = QStringLiteral("gmail");
break;
case AccountType::Outlook:
providerType = QStringLiteral("outlook");
break;
default:
providerType = QStringLiteral("imap");
break;
}
}
Synchronizer *sync = SynchronizerProvider::instance().createSynchronizer(accountId, providerType);
if (!sync) {
emit mailFetchError(accountId, folderId, tr("Failed to create synchronizer"));
return;
}
// Initialize synchronizer with account details before using it
if (!sync->initialize(*acc)) {
delete acc;
emit mailFetchError(accountId, folderId, tr("Failed to initialize synchronizer"));
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);
QObject::connect(sync, SIGNAL(statusMessage(const QString&)), this, SIGNAL(statusMessage(const QString&)), Qt::QueuedConnection);
QFuture<QVector<MailItem>> future = QtConcurrent::run([=]() {
// SinceUid = 0 for full sync; could be stored per folder but omitted for simplicity
return sync->fetchMailItems(folderId, 0);
});
QFutureWatcher<QVector<MailItem>> *watcher = new QFutureWatcher<QVector<MailItem>>(this);
QObject::connect(watcher, &QFutureWatcher<QVector<MailItem>>::finished, this, [=]() {
// Disconnect progress signals
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, 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;
qint64 id = mailItemId.toLongLong(&ok);
if (!ok) return;
std::optional<MailItem> opt = MailItemDao::findById(id);
if (!opt) return;
MailItem item = *opt;
item.setFolderId(targetFolderId.toInt());
if (MailItemDao::update(item))
emit mailMoved(mailItemId);
}
void MailService::deleteMail(const QString &mailItemId)
{
bool ok;
qint64 id = mailItemId.toLongLong(&ok);
if (!ok) return;
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)
{
bool ok;
qint64 id = mailItemId.toLongLong(&ok);
if (!ok) return;
std::optional<MailItem> opt = MailItemDao::findById(id);
if (!opt) return;
MailItem item = *opt;
item.setRead(read);
if (MailItemDao::update(item))
emit mailReadStateChanged(mailItemId, read);
}
#include "mailservice.moc"