[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.
This commit is contained in:
Lukas Brübach 2026-09-04 11:41:18 +02:00
parent 4e9d148163
commit c49f0d162b
7 changed files with 527 additions and 127 deletions

View file

@ -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"

View file

@ -32,6 +32,7 @@
#include <QDateTime>
#include <QDebug>
#include <QFile>
#include <QLoggingCategory>
#include <QProcessEnvironment>
#include <QSqlQuery>
#include <QString>
@ -46,6 +47,8 @@
#include <libcockatrice/protocol/pb/event_server_shutdown.pb.h>
#include <server_room.h>
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;

View file

@ -20,6 +20,8 @@
#ifndef SERVATRICE_H
#define SERVATRICE_H
#include "smtpclient.h"
#include <QDateTime>
#include <QHostAddress>
#include <QMetaType>
@ -166,6 +168,7 @@ private:
QMap<QString, bool> 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);

View file

@ -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;

View file

@ -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);

View file

@ -3,13 +3,35 @@
#include "settingscache.h"
#include "smtp/qxtsmtp.h"
#include <QLoggingCategory>
#include <QSslSocket>
#include <QTcpSocket>
#include <QString>
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<int>(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<qint64>(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<QString> 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);
}
}

View file

@ -1,24 +1,57 @@
#ifndef SMTPCLIENT_H
#define SMTPCLIENT_H
#include "smtp/qxtmailmessage.h"
#include <QDateTime>
#include <QHash>
#include <QObject>
#include <QTimer>
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<QString, PendingEmail> pendingEmails;
QHash<int, QString> mailIdToUser;
QTimer *idleTimer;
int connectionAttempts = 0;
QDateTime connectionBackoffUntil;
};
#endif
#endif