mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 09:33:25 -08:00
[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:
parent
1a4d89ff8f
commit
8968709642
7 changed files with 655 additions and 133 deletions
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue