From c49f0d162bd62ce732d972445b04a35a5e480097 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Fri, 4 Sep 2026 11:41:18 +0200 Subject: [PATCH] [SMTP] Add reliable email delivery with retry and database-backed queue Rework the SMTP client from fire-and-forget sending into a persistent queue with exponential backoff and per-message retry budget, backed by the existing database rows as the durable source of truth. - Keep pending emails in an in-memory queue with attempts/backoff tracking; enqueue without deleting database rows, and only remove the row once delivery is confirmed via the mailDelivered signal. - Add exponential backoff (doubling, capped at 30 min) between delivery attempts, bounded by new smtp/maxretries and smtp/retrydelay settings. - Track connection-layer failures (connect/auth/encrypt) with their own backoff; route failures to recordFailed/recordConnectionFailure so undeliverable mail eventually drops from the queue instead of retrying forever. - Treat rejected recipients/senders as permanent failures. - Rebuild the SMTP buffer from the authoritative queue before each send via a new QxtSmtp::reset() so reconnects never duplicate deliveries. - Start the idle timeout after authentication, not on TCP connect, so slow SSL/STARTTLS handshakes are not cut short. --- servatrice/servatrice.ini.example | 10 + servatrice/src/servatrice.cpp | 76 ++++- servatrice/src/servatrice.h | 5 + servatrice/src/smtp/qxtsmtp.cpp | 32 +++ servatrice/src/smtp/qxtsmtp.h | 2 + servatrice/src/smtpclient.cpp | 444 +++++++++++++++++++++++------- servatrice/src/smtpclient.h | 85 +++++- 7 files changed, 527 insertions(+), 127 deletions(-) diff --git a/servatrice/servatrice.ini.example b/servatrice/servatrice.ini.example index c1940c22f..b8b4eb30e 100644 --- a/servatrice/servatrice.ini.example +++ b/servatrice/servatrice.ini.example @@ -244,6 +244,16 @@ email=root@localhost ; Sender email name name="Cockatrice server" +; Maximum number of attempts to deliver a given e-mail before giving up. +; If the smtp server is unreachable or repeatedly rejects a message, the server +; will retry with an exponentially growing delay up to this many times, then drop +; the message from the queue and log the failure. Default: 5 +maxretries=5 + +; Initial retry delay in seconds between delivery attempts. Doubles with each +; failed attempt (capped at 30 minutes). Default: 60 +retrydelay=60 + ; Email subject subject="Cockatrice server account activation token" diff --git a/servatrice/src/servatrice.cpp b/servatrice/src/servatrice.cpp index db8751658..06286025e 100644 --- a/servatrice/src/servatrice.cpp +++ b/servatrice/src/servatrice.cpp @@ -32,6 +32,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,8 @@ #include #include +inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice"); + Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server, int _numberPools, const QSqlDatabase &_sqlDatabase, @@ -305,6 +308,11 @@ bool Servatrice::initServer() servatriceDatabaseInterface->clearSessionTables(); } + // Connect the SMTP client's delivery-confirmation signals to the database cleanup handlers. + // Emails are removed from the queue tables only after delivery is confirmed (see statusUpdate()). + connect(smtpClient, &SmtpClient::mailDelivered, this, &Servatrice::onEmailDelivered); + connect(smtpClient, &SmtpClient::mailPermanentlyFailed, this, &Servatrice::onEmailPermanentlyFailed); + if (getRoomsMethodString() == "sql") { QSqlQuery *query = servatriceDatabaseInterface->prepareQuery( "select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from " @@ -648,18 +656,16 @@ void Servatrice::statusUpdate() return; } - auto *queryDelete = - servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name"); - + // Rows are intentionally NOT deleted here: the durable source of truth for a pending + // activation e-mail is the database row, which is only removed once delivery has been + // confirmed by the SMTP client (see onEmailDelivered). This makes sending resilient to + // transient SMTP failures and server restarts. while (servDbSelQuery->next()) { const QString userName = servDbSelQuery->value(0).toString(); const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString()); const QString token = servDbSelQuery->value(2).toString(); - if (smtpClient->enqueueActivationTokenMail(userName, emailAddress, token)) { - queryDelete->bindValue(":name", userName); - servatriceDatabaseInterface->execSqlQuery(queryDelete); - } + smtpClient->enqueueActivationTokenMail(userName, emailAddress, token); } } @@ -671,18 +677,14 @@ void Servatrice::statusUpdate() return; } - QSqlQuery *queryDelete = servatriceDatabaseInterface->prepareQuery( - "update {prefix}_forgot_password set emailed = 1 where name = :name"); - + // Rows are intentionally left with emailed = 0 here; they are marked emailed only once + // delivery is confirmed by the SMTP client (see onEmailDelivered). while (forgotPwQuery->next()) { const QString userName = forgotPwQuery->value(0).toString(); const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(1).toString()); const QString token = forgotPwQuery->value(2).toString(); - if (smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token)) { - queryDelete->bindValue(":name", userName); - servatriceDatabaseInterface->execSqlQuery(queryDelete); - } + smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token); } } @@ -690,6 +692,52 @@ void Servatrice::statusUpdate() } } +void Servatrice::onEmailDelivered(const QString &userName, EmailType type) +{ + if (cleanupDatabaseForEmail(userName, type)) { + qCDebug(ServatriceLog) << "E-mail delivered to" << userName; + } +} + +void Servatrice::onEmailPermanentlyFailed(const QString &userName, EmailType type, FailureReason reason) +{ + switch (reason) { + case FailureReason::RetryExhausted: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed after max retries"; + break; + case FailureReason::RecipientRejected: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: recipient rejected"; + break; + case FailureReason::SenderRejected: + qCWarning(ServatriceLog) << "E-mail to" << userName << "permanently failed: sender rejected"; + break; + } + + if (!cleanupDatabaseForEmail(userName, type)) { + qCWarning(ServatriceLog) << "Failed to clean up database row for permanently failed e-mail to" << userName + << "- row may be re-enqueued on next status update"; + } +} + +bool Servatrice::cleanupDatabaseForEmail(const QString &userName, EmailType type) +{ + switch (type) { + case EmailType::Activation: { + QSqlQuery *query = + servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name"); + query->bindValue(":name", userName); + return servatriceDatabaseInterface->execSqlQuery(query); + } + case EmailType::ForgotPassword: { + QSqlQuery *query = servatriceDatabaseInterface->prepareQuery( + "update {prefix}_forgot_password set emailed = 1 where name = :name"); + query->bindValue(":name", userName); + return servatriceDatabaseInterface->execSqlQuery(query); + } + } + return false; +} + SessionEvent *Servatrice::makeShutdownEvent() const { Event_ServerShutdown event; diff --git a/servatrice/src/servatrice.h b/servatrice/src/servatrice.h index 8b0f5ad60..2101e9118 100644 --- a/servatrice/src/servatrice.h +++ b/servatrice/src/servatrice.h @@ -20,6 +20,8 @@ #ifndef SERVATRICE_H #define SERVATRICE_H +#include "smtpclient.h" + #include #include #include @@ -166,6 +168,7 @@ private: QMap serverRequiredFeatureList; QString officialWarnings; Servatrice_DatabaseInterface *servatriceDatabaseInterface; + bool cleanupDatabaseForEmail(const QString &userName, EmailType type); int serverId; int uptime; QMutex txBytesMutex, rxBytesMutex; @@ -210,6 +213,8 @@ public slots: void scheduleShutdown(const QString &reason, int minutes); void updateLoginMessage(); void setRequiredFeatures(const QString &featureList); + void onEmailDelivered(const QString &userName, EmailType type); + void onEmailPermanentlyFailed(const QString &userName, EmailType type, FailureReason reason); public: explicit Servatrice(QObject *parent = nullptr); diff --git a/servatrice/src/smtp/qxtsmtp.cpp b/servatrice/src/smtp/qxtsmtp.cpp index c81e0955c..a4da649f7 100644 --- a/servatrice/src/smtp/qxtsmtp.cpp +++ b/servatrice/src/smtp/qxtsmtp.cpp @@ -94,6 +94,38 @@ int QxtSmtp::pendingMessages() const return qxt_d().pending.count(); } +/*! + * \brief Drops all queued (not yet transmitted) messages without sending them. + * + * Any message still awaiting delivery is discarded. This is used by callers that + * keep their own authoritative queue and wish to rebuild the SMTP buffer from scratch + * (e.g. after a reconnect) without risking duplicates. + */ +void QxtSmtp::clearPending() +{ + qxt_d().pending.clear(); +} + +/*! + * \brief Clears the outgoing queue and resets the SMTP state machine. + * + * Unlike clearPending(), this also resets the internal state to Disconnected so + * that subsequent send() calls will not fire sendNext() prematurely on a + * not-yet-connected socket. Use this when rebuilding the queue from scratch + * before a fresh connectToHost() / connectToSecureHost() cycle. + */ +void QxtSmtp::reset() +{ + qxt_d().pending.clear(); + qxt_d().extensions.clear(); + qxt_d().buffer.clear(); + qxt_d().recipients.clear(); + qxt_d().rcptNumber = 0; + qxt_d().rcptAck = 0; + qxt_d().mailAck = false; + qxt_d().state = QxtSmtpPrivate::Disconnected; +} + QTcpSocket *QxtSmtp::socket() const { return qxt_d().socket; diff --git a/servatrice/src/smtp/qxtsmtp.h b/servatrice/src/smtp/qxtsmtp.h index 747e22df9..d264d1047 100644 --- a/servatrice/src/smtp/qxtsmtp.h +++ b/servatrice/src/smtp/qxtsmtp.h @@ -66,6 +66,8 @@ public: int send(const QxtMailMessage& message); int pendingMessages() const; + void clearPending(); + void reset(); QTcpSocket* socket() const; void connectToHost(const QString& hostName, quint16 port = 25); diff --git a/servatrice/src/smtpclient.cpp b/servatrice/src/smtpclient.cpp index 3ec333413..cf9728540 100644 --- a/servatrice/src/smtpclient.cpp +++ b/servatrice/src/smtpclient.cpp @@ -3,13 +3,35 @@ #include "settingscache.h" #include "smtp/qxtsmtp.h" +#include #include -#include +#include + +static const qint64 DEFAULT_MAX_RETRIES = 5; +static const qint64 DEFAULT_RETRY_DELAY = 60; +static const qint64 MAX_RETRY_DELAY = 1800; // 30 minutes +static const qint64 IDLE_TIMEOUT_MS = 60000; // 60 seconds +// Maximum bit-shift exponent used in the exponential back-off formula. +// With a base delay of 60 s, shift 10 gives 60 * 1024 ≈ 17 h before the +// MAX_RETRY_DELAY cap is applied, preventing unbounded intermediate values. +static const int MAX_BACKOFF_SHIFT = 10; + +inline Q_LOGGING_CATEGORY(SmtpClientLog, "smtp_client") + + static QString pendingKey(const QString &userName, EmailType type) +{ + const char *typeName = (type == EmailType::Activation) ? "activation" : "forgotpassword"; + return userName + '/' + typeName; +} SmtpClient::SmtpClient(QObject *parent) : QObject(parent) { smtp = new QxtSmtp(this); + idleTimer = new QTimer(this); + idleTimer->setSingleShot(true); + connect(idleTimer, SIGNAL(timeout()), this, SLOT(idleTimeout())); + connect(smtp, SIGNAL(authenticated()), this, SLOT(authenticated())); connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this, SLOT(authenticationFailed(const QByteArray &))); @@ -32,104 +54,123 @@ SmtpClient::~SmtpClient() { if (smtp) { delete smtp; - smtp = 0; + smtp = nullptr; } } +int SmtpClient::maxRetries() const +{ + return settingsCache->value("smtp/maxretries", DEFAULT_MAX_RETRIES).toInt(); +} + +int SmtpClient::retryDelaySeconds() const +{ + return qBound(1, settingsCache->value("smtp/retrydelay", DEFAULT_RETRY_DELAY).toInt(), + static_cast(MAX_RETRY_DELAY)); +} + +QDateTime SmtpClient::nextRetryTime(int attempts, int retryDelay) const +{ + // Exponential backoff, capped to avoid hammering an unreachable server. + const int shift = qMax(0, qMin(attempts - 1, MAX_BACKOFF_SHIFT)); + const qint64 delay = qMin(retryDelay * qint64(1) << shift, MAX_RETRY_DELAY); + return QDateTime::currentDateTime().addSecs(delay); +} + +SmtpClient::PendingEmail SmtpClient::buildEmail(const QString &nickname, + EmailType type, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &email) +{ + QString name = settingsCache->value("smtp/name", "").toString(); + + PendingEmail pending; + pending.userName = nickname; + pending.type = type; + pending.maxAttempts = maxRetries(); + pending.retryDelay = retryDelaySeconds(); + pending.nextAttempt = QDateTime::currentDateTime(); + + QString emailBody = body; + emailBody.replace("%username", nickname).replace("%token", token); + + pending.message.setSender(name + " <" + email + ">"); + pending.message.addRecipient(recipient); + pending.message.setSubject(subject); + pending.message.setBody(emailBody); + + return pending; +} + +bool SmtpClient::enqueue(const PendingEmail &email) +{ + const QString key = pendingKey(email.userName, email.type); + if (pendingEmails.contains(key)) { + qCDebug(SmtpClientLog) << "Email already pending for" << email.userName << "- not duplicating"; + return false; + } + + pendingEmails.insert(key, email); + qCDebug(SmtpClientLog) << "Enqueued" << (email.type == EmailType::Activation ? "activation" : "password reset") + << "mail to" << email.userName; + return true; +} + bool SmtpClient::enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token) { - QString email = settingsCache->value("smtp/email", "").toString(); - QString name = settingsCache->value("smtp/name", "").toString(); + if (pendingEmails.contains(pendingKey(nickname, EmailType::Activation))) { + qCDebug(SmtpClientLog) << "Activation email already pending for" << nickname << "- skipping"; + return false; + } + QString subject = settingsCache->value("smtp/subject", "").toString(); QString body = settingsCache->value("smtp/body", "").toString(); + QString email = settingsCache->value("smtp/email", "").toString(); - if (email.isEmpty()) { - qDebug() << "[MAIL] Missing sender email in configuration"; + if (!validateEmailFields(email, subject, body, recipient, token, nickname)) { return false; } - if (subject.isEmpty()) { - qDebug() << "[MAIL] Missing subject field in configuration"; - return false; - } - - if (body.isEmpty()) { - qDebug() << "[MAIL] Missing body field in configuration"; - return false; - } - - if (recipient.isEmpty()) { - qDebug() << "[MAIL] Missing recipient field for user " << nickname; - return false; - } - - if (token.isEmpty()) { - qDebug() << "[MAIL] Missing token field for user " << nickname; - return false; - } - - QxtMailMessage message; - message.setSender(name + " <" + email + ">"); - message.addRecipient(recipient); - message.setSubject(subject); - message.setBody(body.replace("%username", nickname).replace("%token", token)); - - int id = smtp->send(message); - qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id; - return true; + return enqueue(buildEmail(nickname, EmailType::Activation, subject, body, recipient, token, email)); } bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token) { - QString email = settingsCache->value("smtp/email", "").toString(); - QString name = settingsCache->value("smtp/name", "").toString(); + if (pendingEmails.contains(pendingKey(nickname, EmailType::ForgotPassword))) { + qCDebug(SmtpClientLog) << "Password reset email already pending for" << nickname << "- skipping"; + return false; + } + QString subject = settingsCache->value("forgotpassword/subject", "").toString(); QString body = settingsCache->value("forgotpassword/body", "").toString(); + QString email = settingsCache->value("smtp/email", "").toString(); - if (email.isEmpty()) { - qDebug() << "[MAIL] Missing sender email in configuration"; + if (!validateEmailFields(email, subject, body, recipient, token, nickname)) { return false; } - if (subject.isEmpty()) { - qDebug() << "[MAIL] Missing subject field in configuration"; - return false; - } - - if (body.isEmpty()) { - qDebug() << "[MAIL] Missing body field in configuration"; - return false; - } - - if (recipient.isEmpty()) { - qDebug() << "[MAIL] Missing recipient field for user " << nickname; - return false; - } - - if (token.isEmpty()) { - qDebug() << "[MAIL] Missing token field for user " << nickname; - return false; - } - - QxtMailMessage message; - message.setSender(name + " <" + email + ">"); - message.addRecipient(recipient); - message.setSubject(subject); - message.setBody(body.replace("%username", nickname).replace("%token", token)); - - int id = smtp->send(message); - qDebug() << "[MAIL] Enqueued mail to" << recipient << "as" << id; - return true; + return enqueue(buildEmail(nickname, EmailType::ForgotPassword, subject, body, recipient, token, email)); } void SmtpClient::sendAllEmails() { // still connected from the previous round - if (smtp->socket()->state() == QAbstractSocket::ConnectedState) { + if (smtp->socket()->state() == QAbstractSocket::ConnectedState || + smtp->socket()->state() == QAbstractSocket::ConnectingState || + smtp->socket()->state() == QAbstractSocket::HostLookupState || + smtp->socket()->state() == QAbstractSocket::ClosingState) { return; } - if (smtp->pendingMessages() == 0) { + if (pendingEmails.isEmpty()) { + return; + } + + // Respect connection-level backoff after auth/conn/encryption failures. + if (connectionAttempts > 0 && QDateTime::currentDateTime() < connectionBackoffUntil) { return; } @@ -143,6 +184,16 @@ void SmtpClient::sendAllEmails() smtp->setUsername(username); smtp->setPassword(password); + // Push due emails into QxtSmtp *before* connecting so they are already + // queued when the authenticated() signal fires and QxtSmtp's internal + // sendNext() runs. + relayoutForSending(); + + // Nothing due right now — skip the SMTP round-trip. + if (mailIdToUser.isEmpty()) { + return; + } + // Connect if (connectionType == "ssl") { if (acceptAllCerts) { @@ -154,64 +205,243 @@ void SmtpClient::sendAllEmails() } } -void SmtpClient::authenticated() +void SmtpClient::relayoutForSending() { - qDebug() << "[MAIL] authenticated"; -} + // Rebuild the SMTP buffer exclusively from our authoritative set, so a reconnect + // after a partial transmission never results in duplicate deliveries. + // reset() also clears the internal state machine so that send() does not + // fire sendNext() prematurely on a not-yet-connected socket. + smtp->reset(); + mailIdToUser.clear(); -void SmtpClient::authenticationFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] authenticationFailed" << QString(msg); -} - -void SmtpClient::connected() -{ - qDebug() << "[MAIL] connected"; -} - -void SmtpClient::connectionFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] connectionFailed" << QString(msg); -} - -void SmtpClient::disconnected() -{ - qDebug() << "[MAIL] disconnected"; -} - -void SmtpClient::encrypted() -{ - qDebug() << "[MAIL] encrypted"; -} - -void SmtpClient::encryptionFailed(const QByteArray &msg) -{ - qDebug() << "[MAIL] encryptionFailed" << QString(msg); - qDebug() << "[MAIL] Try enabling the \"acceptallcerts\" option in servatrice.ini"; + const QDateTime now = QDateTime::currentDateTime(); + for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) { + PendingEmail &pending = it.value(); + if (pending.nextAttempt > now) { + continue; // not yet due; honours the backoff schedule + } + int id = smtp->send(pending.message); + mailIdToUser.insert(id, pendingKey(pending.userName, pending.type)); + } } void SmtpClient::finished() { - qDebug() << "[MAIL] finished"; + qCDebug(SmtpClientLog) << "finished"; smtp->disconnectFromHost(); } +void SmtpClient::authenticated() +{ + qCDebug(SmtpClientLog) << "authenticated"; + // Start the idle timer here, once the connection is fully ready to send, + // rather than on TCP connect: SSL/STARTTLS handshake and auth may take a + // while and must not be cut short by the idle timeout. + idleTimer->start(IDLE_TIMEOUT_MS); +} + +void SmtpClient::authenticationFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "authenticationFailed" << QString::fromUtf8(msg); + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::connected() +{ + qCDebug(SmtpClientLog) << "connected"; + connectionAttempts = 0; +} + +void SmtpClient::connectionFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "connectionFailed" << QString::fromUtf8(msg); + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::disconnected() +{ + qCDebug(SmtpClientLog) << "disconnected - emails still pending:" << pendingEmails.count(); + idleTimer->stop(); +} + +void SmtpClient::encrypted() +{ + qCDebug(SmtpClientLog) << "encrypted"; +} + +void SmtpClient::encryptionFailed(const QByteArray &msg) +{ + qCWarning(SmtpClientLog) << "encryptionFailed" << QString::fromUtf8(msg); + qCWarning(SmtpClientLog) << "Try enabling the \"acceptallcerts\" option in servatrice.ini"; + idleTimer->stop(); + recordConnectionFailure(); + mailIdToUser.clear(); + smtp->disconnectFromHost(); +} + +void SmtpClient::idleTimeout() +{ + qCDebug(SmtpClientLog) << "idle timeout - disconnecting"; + smtp->disconnectFromHost(); +} + +void SmtpClient::recordFailed(int mailID) +{ + auto it = mailIdToUser.find(mailID); + if (it == mailIdToUser.end()) { + return; + } + const QString key = it.value(); + mailIdToUser.erase(it); + + auto pendingIt = pendingEmails.find(key); + if (pendingIt == pendingEmails.end()) { + return; + } + + PendingEmail &pending = pendingIt.value(); + pending.attempts++; + + if (pending.attempts >= pending.maxAttempts) { + const QString userName = pending.userName; + const EmailType type = pending.type; + qCWarning(SmtpClientLog) << "Email to" << userName << "permanently failed after" << pending.attempts + << "attempts - dropping from pending set"; + pendingEmails.erase(pendingIt); + emit mailPermanentlyFailed(userName, type, FailureReason::RetryExhausted); + return; + } + + pending.nextAttempt = nextRetryTime(pending.attempts, pending.retryDelay); + qCWarning(SmtpClientLog) << "Email to" << pending.userName << "failed (attempt" << pending.attempts << "of" + << pending.maxAttempts << "), retrying" << pending.nextAttempt.toString(Qt::ISODate); +} + +void SmtpClient::recordConnectionFailure() +{ + ++connectionAttempts; + connectionBackoffUntil = nextRetryTime(connectionAttempts, retryDelaySeconds()); + qCWarning(SmtpClientLog) << "Connection-layer failure #" << connectionAttempts << "- next attempt after" + << connectionBackoffUntil.toString(Qt::ISODate); + + // Connection failures count toward the per-email retry budget so that + // undeliverable mail eventually gives up rather than retrying forever. + QList keysToRemove; + for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) { + PendingEmail &pending = it.value(); + pending.attempts++; + if (pending.attempts >= pending.maxAttempts) { + qCWarning(SmtpClientLog) << "Email to" << pending.userName << "permanently failed after" << pending.attempts + << "connection failures - dropping from pending set"; + keysToRemove.append(it.key()); + emit mailPermanentlyFailed(pending.userName, pending.type, FailureReason::RetryExhausted); + } + } + for (const QString &key : keysToRemove) { + pendingEmails.remove(key); + } +} + +bool SmtpClient::validateEmailFields(const QString &email, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &nickname) +{ + if (email.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing sender email in configuration"; + return false; + } + if (subject.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing subject field in configuration"; + return false; + } + if (body.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing body field in configuration"; + return false; + } + if (recipient.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing recipient field for user" << nickname; + return false; + } + if (token.isEmpty()) { + qCCritical(SmtpClientLog) << "Missing token field for user" << nickname; + return false; + } + return true; +} + void SmtpClient::mailFailed(int mailID, int errorCode, const QByteArray &msg) { - qDebug() << "[MAIL] mailFailed id=" << mailID << " errorCode=" << errorCode << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "mailFailed id=" << mailID << " errorCode=" << errorCode + << "msg=" << QString::fromUtf8(msg); + recordFailed(mailID); } void SmtpClient::mailSent(int mailID) { - qDebug() << "[MAIL] mailSent" << mailID; + qCDebug(SmtpClientLog) << "mailSent" << mailID; + + auto it = mailIdToUser.find(mailID); + if (it == mailIdToUser.end()) { + return; + } + const QString key = it.value(); + mailIdToUser.erase(it); + + auto pendingIt = pendingEmails.find(key); + if (pendingIt == pendingEmails.end()) { + return; + } + + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + pendingEmails.erase(pendingIt); + qCDebug(SmtpClientLog) << "Delivered email to" << userName; + emit mailDelivered(userName, type); } void SmtpClient::recipientRejected(int mailID, const QString &address, const QByteArray &msg) { - qDebug() << "[MAIL] recipientRejected id=" << mailID << " address=" << address << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "recipientRejected id=" << mailID << " address=" << address + << "msg=" << QString::fromUtf8(msg); + // A rejected recipient is a permanent condition; consider the mail undeliverable. + auto it = mailIdToUser.find(mailID); + if (it != mailIdToUser.end()) { + const QString key = it.value(); + if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) { + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + pendingEmails.erase(pendingIt); + qCWarning(SmtpClientLog) << "Email to" << userName << "rejected - dropping from pending set"; + emit mailPermanentlyFailed(userName, type, FailureReason::RecipientRejected); + } + mailIdToUser.erase(it); + } } void SmtpClient::senderRejected(int mailID, const QString &address, const QByteArray &msg) { - qDebug() << "[MAIL] senderRejected id=" << mailID << " address=" << address << "msg=" << QString(msg); + qCWarning(SmtpClientLog) << "senderRejected id=" << mailID << " sender=" << address + << "msg=" << QString::fromUtf8(msg); + // A rejected sender is a permanent condition; consider the mail undeliverable. + auto it = mailIdToUser.find(mailID); + if (it != mailIdToUser.end()) { + const QString key = it.value(); + if (auto pendingIt = pendingEmails.find(key); pendingIt != pendingEmails.end()) { + const QString userName = pendingIt.value().userName; + const EmailType type = pendingIt.value().type; + pendingEmails.erase(pendingIt); + qCWarning(SmtpClientLog) << "Email to" << userName << "sender rejected - dropping from pending set"; + emit mailPermanentlyFailed(userName, type, FailureReason::SenderRejected); + } + mailIdToUser.erase(it); + } } diff --git a/servatrice/src/smtpclient.h b/servatrice/src/smtpclient.h index be97ed44d..8008f1b50 100644 --- a/servatrice/src/smtpclient.h +++ b/servatrice/src/smtpclient.h @@ -1,24 +1,57 @@ #ifndef SMTPCLIENT_H #define SMTPCLIENT_H +#include "smtp/qxtmailmessage.h" + +#include +#include #include +#include class QxtSmtp; -class QxtMailMessage; + +/** + * @brief Types of e-mail the SMTP client is able to deliver. + */ +enum class EmailType +{ + Activation, + ForgotPassword +}; + +Q_DECLARE_METATYPE(EmailType) + +/** + * @brief Why a permanent e-mail delivery failure occurred. + */ +enum class FailureReason +{ + RetryExhausted, + RecipientRejected, + SenderRejected +}; + +Q_DECLARE_METATYPE(FailureReason) class SmtpClient : public QObject { Q_OBJECT public: - SmtpClient(QObject *parent = 0); - ~SmtpClient(); + SmtpClient(QObject *parent = nullptr); + ~SmtpClient() override; -protected: - QxtSmtp *smtp; public slots: bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token); bool enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token); void sendAllEmails(); + +signals: + void mailDelivered(const QString &userName, EmailType type); + void mailPermanentlyFailed(const QString &userName, EmailType type, FailureReason reason); + +protected: + QxtSmtp *smtp; + protected slots: void authenticated(); void authenticationFailed(const QByteArray &msg); @@ -28,10 +61,50 @@ protected slots: void encrypted(); void encryptionFailed(const QByteArray &msg); void finished(); + void idleTimeout(); void mailFailed(int mailID, int errorCode, const QByteArray &msg); void mailSent(int mailID); void recipientRejected(int mailID, const QString &address, const QByteArray &msg); void senderRejected(int mailID, const QString &address, const QByteArray &msg); + +private: + struct PendingEmail + { + QString userName; + EmailType type; + QxtMailMessage message; + int attempts = 0; + int maxAttempts = 0; + int retryDelay = 0; + QDateTime nextAttempt; + }; + + PendingEmail buildEmail(const QString &nickname, + EmailType type, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &email); + bool enqueue(const PendingEmail &email); + void relayoutForSending(); + QDateTime nextRetryTime(int attempts, int retryDelay) const; + void recordFailed(int mailID); + void recordConnectionFailure(); + bool validateEmailFields(const QString &email, + const QString &subject, + const QString &body, + const QString &recipient, + const QString &token, + const QString &nickname); + int maxRetries() const; + int retryDelaySeconds() const; + + QHash pendingEmails; + QHash mailIdToUser; + QTimer *idleTimer; + int connectionAttempts = 0; + QDateTime connectionBackoffUntil; }; -#endif \ No newline at end of file +#endif