[SMTP] Add reliable email delivery with retry and database-backed queue (#7240)

* [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.

* [SMTP] Fix retry accounting, backoff reset, and token dedupe

---------

Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
BruebachL 2026-10-11 20:59:32 +02:00 • committed by GitHub
parent 1a4d89ff8f
commit 8968709642
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 655 additions and 133 deletions

View file

@ -38,6 +38,7 @@
#include <QDebug>
#include <QFile>
#include <QIODevice>
#include <QLoggingCategory>
#include <QMapIterator>
#include <QMetaObject>
#include <QMutableListIterator>
@ -69,6 +70,8 @@
#include <server_room.h>
#include <string>
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
int _numberPools,
const QSqlDatabase &_sqlDatabase,
@ -335,6 +338,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 "
@ -737,41 +745,36 @@ 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);
}
}
if (getEnableForgotPassword()) {
auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery(
"select a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on a.name "
"= b.name where a.emailed = 0");
"select a.id, a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on "
"a.name = b.name where a.emailed = 0");
if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) {
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();
const qint64 rowId = forgotPwQuery->value(0).toLongLong();
const QString userName = forgotPwQuery->value(1).toString();
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(2).toString());
const QString token = forgotPwQuery->value(3).toString();
if (smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token)) {
queryDelete->bindValue(":name", userName);
servatriceDatabaseInterface->execSqlQuery(queryDelete);
}
smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token, rowId);
}
}
@ -779,6 +782,69 @@ void Servatrice::statusUpdate()
}
}
void Servatrice::onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId)
{
if (cleanupDatabaseForEmail(userName, type, token, rowId)) {
qCDebug(ServatriceLog) << "E-mail delivered to" << userName;
} else {
qCWarning(ServatriceLog) << "Failed to clean up database row for delivered e-mail to" << userName
<< "- row may be re-enqueued on next status update";
}
}
void Servatrice::onEmailPermanentlyFailed(const QString &userName,
EmailType type,
FailureReason reason,
const QString &token,
qint64 rowId)
{
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, token, rowId)) {
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, const QString &token, qint64 rowId)
{
Q_UNUSED(token);
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: {
// Scope the update to the row that was actually sent: the table
// holds one row per request, and marking by name would also mark
// rows for newer tokens that were never delivered.
if (rowId >= 0) {
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
"update {prefix}_forgot_password set emailed = 1 where id = :id");
query->bindValue(":id", rowId);
return servatriceDatabaseInterface->execSqlQuery(query);
}
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;