mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-12 21:10:01 -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
|
|
@ -250,6 +250,16 @@ email=root@localhost
|
||||||
; Sender email name
|
; Sender email name
|
||||||
name="Cockatrice server"
|
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
|
; Email subject
|
||||||
subject="Cockatrice server account activation token"
|
subject="Cockatrice server account activation token"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
#include <QFile>
|
#include <QFile>
|
||||||
#include <QIODevice>
|
#include <QIODevice>
|
||||||
|
#include <QLoggingCategory>
|
||||||
#include <QMapIterator>
|
#include <QMapIterator>
|
||||||
#include <QMetaObject>
|
#include <QMetaObject>
|
||||||
#include <QMutableListIterator>
|
#include <QMutableListIterator>
|
||||||
|
|
@ -69,6 +70,8 @@
|
||||||
#include <server_room.h>
|
#include <server_room.h>
|
||||||
#include <string>
|
#include <string>
|
||||||
|
|
||||||
|
inline Q_LOGGING_CATEGORY(ServatriceLog, "servatrice");
|
||||||
|
|
||||||
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
Servatrice_GameServer::Servatrice_GameServer(Servatrice *_server,
|
||||||
int _numberPools,
|
int _numberPools,
|
||||||
const QSqlDatabase &_sqlDatabase,
|
const QSqlDatabase &_sqlDatabase,
|
||||||
|
|
@ -335,6 +338,11 @@ bool Servatrice::initServer()
|
||||||
servatriceDatabaseInterface->clearSessionTables();
|
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") {
|
if (getRoomsMethodString() == "sql") {
|
||||||
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
QSqlQuery *query = servatriceDatabaseInterface->prepareQuery(
|
||||||
"select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from "
|
"select id, name, descr, permissionlevel, privlevel, auto_join, join_message, chat_history_size from "
|
||||||
|
|
@ -737,41 +745,36 @@ void Servatrice::statusUpdate()
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
auto *queryDelete =
|
// Rows are intentionally NOT deleted here: the durable source of truth for a pending
|
||||||
servatriceDatabaseInterface->prepareQuery("delete from {prefix}_activation_emails where name = :name");
|
// 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()) {
|
while (servDbSelQuery->next()) {
|
||||||
const QString userName = servDbSelQuery->value(0).toString();
|
const QString userName = servDbSelQuery->value(0).toString();
|
||||||
const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString());
|
const auto emailAddress = EmailParser::getParsedEmailAddress(servDbSelQuery->value(1).toString());
|
||||||
const QString token = servDbSelQuery->value(2).toString();
|
const QString token = servDbSelQuery->value(2).toString();
|
||||||
|
|
||||||
if (smtpClient->enqueueActivationTokenMail(userName, emailAddress, token)) {
|
smtpClient->enqueueActivationTokenMail(userName, emailAddress, token);
|
||||||
queryDelete->bindValue(":name", userName);
|
|
||||||
servatriceDatabaseInterface->execSqlQuery(queryDelete);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (getEnableForgotPassword()) {
|
if (getEnableForgotPassword()) {
|
||||||
auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery(
|
auto *forgotPwQuery = servatriceDatabaseInterface->prepareQuery(
|
||||||
"select a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on a.name "
|
"select a.id, a.name, b.email, b.token from {prefix}_forgot_password a left join {prefix}_users b on "
|
||||||
"= b.name where a.emailed = 0");
|
"a.name = b.name where a.emailed = 0");
|
||||||
if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) {
|
if (!servatriceDatabaseInterface->execSqlQuery(forgotPwQuery)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
QSqlQuery *queryDelete = servatriceDatabaseInterface->prepareQuery(
|
// Rows are intentionally left with emailed = 0 here; they are marked emailed only once
|
||||||
"update {prefix}_forgot_password set emailed = 1 where name = :name");
|
// delivery is confirmed by the SMTP client (see onEmailDelivered).
|
||||||
|
|
||||||
while (forgotPwQuery->next()) {
|
while (forgotPwQuery->next()) {
|
||||||
const QString userName = forgotPwQuery->value(0).toString();
|
const qint64 rowId = forgotPwQuery->value(0).toLongLong();
|
||||||
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(1).toString());
|
const QString userName = forgotPwQuery->value(1).toString();
|
||||||
const QString token = forgotPwQuery->value(2).toString();
|
const auto emailAddress = EmailParser::getParsedEmailAddress(forgotPwQuery->value(2).toString());
|
||||||
|
const QString token = forgotPwQuery->value(3).toString();
|
||||||
|
|
||||||
if (smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token)) {
|
smtpClient->enqueueForgotPasswordTokenMail(userName, emailAddress, token, rowId);
|
||||||
queryDelete->bindValue(":name", userName);
|
|
||||||
servatriceDatabaseInterface->execSqlQuery(queryDelete);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -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
|
SessionEvent *Servatrice::makeShutdownEvent() const
|
||||||
{
|
{
|
||||||
Event_ServerShutdown event;
|
Event_ServerShutdown event;
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@
|
||||||
#define SERVATRICE_H
|
#define SERVATRICE_H
|
||||||
|
|
||||||
#include "metrics_registry.h"
|
#include "metrics_registry.h"
|
||||||
|
#include "smtpclient.h"
|
||||||
|
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QDateTime>
|
#include <QDateTime>
|
||||||
|
|
@ -181,6 +182,7 @@ private:
|
||||||
QMap<QString, bool> serverRequiredFeatureList;
|
QMap<QString, bool> serverRequiredFeatureList;
|
||||||
QString officialWarnings;
|
QString officialWarnings;
|
||||||
Servatrice_DatabaseInterface *servatriceDatabaseInterface;
|
Servatrice_DatabaseInterface *servatriceDatabaseInterface;
|
||||||
|
bool cleanupDatabaseForEmail(const QString &userName, EmailType type, const QString &token, qint64 rowId);
|
||||||
int serverId;
|
int serverId;
|
||||||
int uptime;
|
int uptime;
|
||||||
QMutex txBytesMutex, rxBytesMutex;
|
QMutex txBytesMutex, rxBytesMutex;
|
||||||
|
|
@ -231,6 +233,12 @@ public slots:
|
||||||
void scheduleShutdown(const QString &reason, int minutes);
|
void scheduleShutdown(const QString &reason, int minutes);
|
||||||
void updateLoginMessage();
|
void updateLoginMessage();
|
||||||
void setRequiredFeatures(const QString &featureList);
|
void setRequiredFeatures(const QString &featureList);
|
||||||
|
void onEmailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId);
|
||||||
|
void onEmailPermanentlyFailed(const QString &userName,
|
||||||
|
EmailType type,
|
||||||
|
FailureReason reason,
|
||||||
|
const QString &token,
|
||||||
|
qint64 rowId);
|
||||||
|
|
||||||
public:
|
public:
|
||||||
explicit Servatrice(QObject *parent = nullptr);
|
explicit Servatrice(QObject *parent = nullptr);
|
||||||
|
|
|
||||||
|
|
@ -94,6 +94,38 @@ int QxtSmtp::pendingMessages() const
|
||||||
return qxt_d().pending.count();
|
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
|
QTcpSocket *QxtSmtp::socket() const
|
||||||
{
|
{
|
||||||
return qxt_d().socket;
|
return qxt_d().socket;
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ public:
|
||||||
|
|
||||||
int send(const QxtMailMessage& message);
|
int send(const QxtMailMessage& message);
|
||||||
int pendingMessages() const;
|
int pendingMessages() const;
|
||||||
|
void clearPending();
|
||||||
|
void reset();
|
||||||
|
|
||||||
QTcpSocket* socket() const;
|
QTcpSocket* socket() const;
|
||||||
void connectToHost(const QString& hostName, quint16 port = 25);
|
void connectToHost(const QString& hostName, quint16 port = 25);
|
||||||
|
|
|
||||||
|
|
@ -7,17 +7,56 @@
|
||||||
#include <QAbstractSocket>
|
#include <QAbstractSocket>
|
||||||
#include <QByteArray>
|
#include <QByteArray>
|
||||||
#include <QDebug>
|
#include <QDebug>
|
||||||
|
#include <QLoggingCategory>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSslSocket>
|
#include <QSslSocket>
|
||||||
|
#include <QString>
|
||||||
#include <QTcpSocket>
|
#include <QTcpSocket>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <qlogging.h>
|
#include <qlogging.h>
|
||||||
#include <qobjectdefs.h>
|
#include <qobjectdefs.h>
|
||||||
|
|
||||||
|
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 QString &token)
|
||||||
|
{
|
||||||
|
const char *typeName = (type == EmailType::Activation) ? "activation" : "forgotpassword";
|
||||||
|
// The token is part of the key: a regenerated token (forgot-password issues a
|
||||||
|
// fresh one per request) must not be suppressed by a stale pending entry.
|
||||||
|
return userName + '/' + typeName + '/' + token;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True only for permanent 5xx replies. Anything else (4xx greylisting/rate
|
||||||
|
// limiting, or an unparsable reply) must be retried, never treated as final:
|
||||||
|
// destroying the durable queue row on a transient failure loses the mail.
|
||||||
|
static bool isPermanentSmtpReply(const QByteArray &msg)
|
||||||
|
{
|
||||||
|
for (char c : msg) {
|
||||||
|
if (c == ' ' || c == '\t' || c == '\r' || c == '\n' || c == '-') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return c == '5';
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
SmtpClient::SmtpClient(QObject *parent) : QObject(parent)
|
SmtpClient::SmtpClient(QObject *parent) : QObject(parent)
|
||||||
{
|
{
|
||||||
smtp = new QxtSmtp(this);
|
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(authenticated()), this, SLOT(authenticated()));
|
||||||
connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this,
|
connect(smtp, SIGNAL(authenticationFailed(const QByteArray &)), this,
|
||||||
SLOT(authenticationFailed(const QByteArray &)));
|
SLOT(authenticationFailed(const QByteArray &)));
|
||||||
|
|
@ -40,104 +79,151 @@ SmtpClient::~SmtpClient()
|
||||||
{
|
{
|
||||||
if (smtp) {
|
if (smtp) {
|
||||||
delete smtp;
|
delete smtp;
|
||||||
smtp = 0;
|
smtp = nullptr;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int SmtpClient::maxRetries() const
|
||||||
|
{
|
||||||
|
// Bounded like retryDelaySeconds(): an unset/blank value must not turn the
|
||||||
|
// first transient failure into a permanent one (attempts >= 0 is immediate).
|
||||||
|
return qBound(1, settingsCache->value("smtp/maxretries", DEFAULT_MAX_RETRIES).toInt(), 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
qint64 rowId)
|
||||||
|
{
|
||||||
|
QString name = settingsCache->value("smtp/name", "").toString();
|
||||||
|
|
||||||
|
PendingEmail pending;
|
||||||
|
pending.userName = nickname;
|
||||||
|
pending.type = type;
|
||||||
|
pending.token = token;
|
||||||
|
pending.rowId = rowId;
|
||||||
|
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, email.token);
|
||||||
|
if (pendingEmails.contains(key)) {
|
||||||
|
qCDebug(SmtpClientLog) << "Email already pending for" << email.userName << "- not duplicating";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A fresher database row for the same user/type supersedes a stale pending
|
||||||
|
// entry: drop the stale one so only the current token is ever delivered.
|
||||||
|
// (Any in-flight mapping for the dropped key is removed as well; the
|
||||||
|
// mailSent/recordFailed lookups tolerate missing entries.)
|
||||||
|
for (auto it = pendingEmails.begin(); it != pendingEmails.end();) {
|
||||||
|
if (it.value().userName == email.userName && it.value().type == email.type) {
|
||||||
|
for (auto mapIt = mailIdToUser.begin(); mapIt != mailIdToUser.end();) {
|
||||||
|
if (mapIt.value() == it.key()) {
|
||||||
|
mapIt = mailIdToUser.erase(mapIt);
|
||||||
|
} else {
|
||||||
|
++mapIt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
qCDebug(SmtpClientLog) << "Replacing stale pending email for" << email.userName;
|
||||||
|
it = pendingEmails.erase(it);
|
||||||
|
} else {
|
||||||
|
++it;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
bool SmtpClient::enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token)
|
||||||
{
|
{
|
||||||
QString email = settingsCache->value("smtp/email", "").toString();
|
if (pendingEmails.contains(pendingKey(nickname, EmailType::Activation, token))) {
|
||||||
QString name = settingsCache->value("smtp/name", "").toString();
|
qCDebug(SmtpClientLog) << "Activation email already pending for" << nickname << "- skipping";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QString subject = settingsCache->value("smtp/subject", "").toString();
|
QString subject = settingsCache->value("smtp/subject", "").toString();
|
||||||
QString body = settingsCache->value("smtp/body", "").toString();
|
QString body = settingsCache->value("smtp/body", "").toString();
|
||||||
|
QString email = settingsCache->value("smtp/email", "").toString();
|
||||||
|
|
||||||
if (email.isEmpty()) {
|
if (!validateEmailFields(email, subject, body, recipient, token, nickname)) {
|
||||||
qDebug() << "[MAIL] Missing sender email in configuration";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subject.isEmpty()) {
|
return enqueue(buildEmail(nickname, EmailType::Activation, subject, body, recipient, token, email));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname, const QString &recipient, const QString &token)
|
bool SmtpClient::enqueueForgotPasswordTokenMail(const QString &nickname,
|
||||||
|
const QString &recipient,
|
||||||
|
const QString &token,
|
||||||
|
qint64 rowId)
|
||||||
{
|
{
|
||||||
QString email = settingsCache->value("smtp/email", "").toString();
|
if (pendingEmails.contains(pendingKey(nickname, EmailType::ForgotPassword, token))) {
|
||||||
QString name = settingsCache->value("smtp/name", "").toString();
|
qCDebug(SmtpClientLog) << "Password reset email already pending for" << nickname << "- skipping";
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
QString subject = settingsCache->value("forgotpassword/subject", "").toString();
|
QString subject = settingsCache->value("forgotpassword/subject", "").toString();
|
||||||
QString body = settingsCache->value("forgotpassword/body", "").toString();
|
QString body = settingsCache->value("forgotpassword/body", "").toString();
|
||||||
|
QString email = settingsCache->value("smtp/email", "").toString();
|
||||||
|
|
||||||
if (email.isEmpty()) {
|
if (!validateEmailFields(email, subject, body, recipient, token, nickname)) {
|
||||||
qDebug() << "[MAIL] Missing sender email in configuration";
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (subject.isEmpty()) {
|
return enqueue(buildEmail(nickname, EmailType::ForgotPassword, subject, body, recipient, token, email, rowId));
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SmtpClient::sendAllEmails()
|
void SmtpClient::sendAllEmails()
|
||||||
{
|
{
|
||||||
// still connected from the previous round
|
// 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;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -151,6 +237,16 @@ void SmtpClient::sendAllEmails()
|
||||||
smtp->setUsername(username);
|
smtp->setUsername(username);
|
||||||
smtp->setPassword(password);
|
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
|
// Connect
|
||||||
if (connectionType == "ssl") {
|
if (connectionType == "ssl") {
|
||||||
if (acceptAllCerts) {
|
if (acceptAllCerts) {
|
||||||
|
|
@ -162,64 +258,286 @@ 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)
|
const QDateTime now = QDateTime::currentDateTime();
|
||||||
{
|
for (auto it = pendingEmails.begin(); it != pendingEmails.end(); ++it) {
|
||||||
qDebug() << "[MAIL] authenticationFailed" << QString(msg);
|
PendingEmail &pending = it.value();
|
||||||
}
|
if (pending.nextAttempt > now) {
|
||||||
|
continue; // not yet due; honours the backoff schedule
|
||||||
void SmtpClient::connected()
|
}
|
||||||
{
|
int id = smtp->send(pending.message);
|
||||||
qDebug() << "[MAIL] connected";
|
mailIdToUser.insert(id, pendingKey(pending.userName, pending.type, pending.token));
|
||||||
}
|
}
|
||||||
|
|
||||||
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";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void SmtpClient::finished()
|
void SmtpClient::finished()
|
||||||
{
|
{
|
||||||
qDebug() << "[MAIL] finished";
|
qCDebug(SmtpClientLog) << "finished";
|
||||||
smtp->disconnectFromHost();
|
smtp->disconnectFromHost();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void SmtpClient::authenticated()
|
||||||
|
{
|
||||||
|
qCDebug(SmtpClientLog) << "authenticated";
|
||||||
|
// The session has actually succeeded (TCP connect alone proves nothing:
|
||||||
|
// TLS/auth may still fail), so the connection-level backoff resets here.
|
||||||
|
connectionAttempts = 0;
|
||||||
|
// 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";
|
||||||
|
// NOTE: connectionAttempts is deliberately not reset here. This fires on
|
||||||
|
// TCP connect, before the TLS handshake and AUTH; resetting here would pin
|
||||||
|
// the connection-level backoff at its base delay for exactly the failures
|
||||||
|
// (bad password/cert/STARTTLS) it is meant to cover. It resets in
|
||||||
|
// authenticated() and on the first successful mailSent instead.
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
// A mid-session drop (peer close/RST past the connect phase, or the idle
|
||||||
|
// timeout firing mid-batch) emits no mailFailed/connectionFailed signal, so
|
||||||
|
// route every in-flight mail through recordFailed(): otherwise attempts and
|
||||||
|
// backoff are bypassed and the mails requeue with stale state forever.
|
||||||
|
const QList<int> inFlight = mailIdToUser.keys();
|
||||||
|
for (int mailID : inFlight) {
|
||||||
|
recordFailed(mailID);
|
||||||
|
}
|
||||||
|
mailIdToUser.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
const QString token = pending.token;
|
||||||
|
const qint64 rowId = pending.rowId;
|
||||||
|
qCWarning(SmtpClientLog) << "Email to" << userName << "permanently failed after" << pending.attempts
|
||||||
|
<< "attempts - dropping from pending set";
|
||||||
|
pendingEmails.erase(pendingIt);
|
||||||
|
emit mailPermanentlyFailed(userName, type, FailureReason::RetryExhausted, token, rowId);
|
||||||
|
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, pending.token,
|
||||||
|
pending.rowId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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)
|
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);
|
||||||
|
// Progress was made (the server answered), so the idle timer measures from
|
||||||
|
// here rather than acting as a cap on the whole session.
|
||||||
|
idleTimer->start(IDLE_TIMEOUT_MS);
|
||||||
|
recordFailed(mailID);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SmtpClient::mailSent(int mailID)
|
void SmtpClient::mailSent(int mailID)
|
||||||
{
|
{
|
||||||
qDebug() << "[MAIL] mailSent" << mailID;
|
qCDebug(SmtpClientLog) << "mailSent" << mailID;
|
||||||
|
// A delivered mail proves the session works: reset the connection-level
|
||||||
|
// backoff and restart the idle timer from this point of progress.
|
||||||
|
connectionAttempts = 0;
|
||||||
|
idleTimer->start(IDLE_TIMEOUT_MS);
|
||||||
|
|
||||||
|
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;
|
||||||
|
const QString token = pendingIt.value().token;
|
||||||
|
const qint64 rowId = pendingIt.value().rowId;
|
||||||
|
pendingEmails.erase(pendingIt);
|
||||||
|
qCDebug(SmtpClientLog) << "Delivered email to" << userName;
|
||||||
|
emit mailDelivered(userName, type, token, rowId);
|
||||||
}
|
}
|
||||||
|
|
||||||
void SmtpClient::recipientRejected(int mailID, const QString &address, const QByteArray &msg)
|
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);
|
||||||
|
if (!isPermanentSmtpReply(msg)) {
|
||||||
|
// Transient 4xx (e.g. greylisting): consume one attempt and reschedule
|
||||||
|
// with backoff instead of destroying the durable queue row.
|
||||||
|
recordFailed(mailID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
const QString token = pendingIt.value().token;
|
||||||
|
const qint64 rowId = pendingIt.value().rowId;
|
||||||
|
pendingEmails.erase(pendingIt);
|
||||||
|
qCWarning(SmtpClientLog) << "Email to" << userName << "rejected - dropping from pending set";
|
||||||
|
emit mailPermanentlyFailed(userName, type, FailureReason::RecipientRejected, token, rowId);
|
||||||
|
}
|
||||||
|
mailIdToUser.erase(it);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void SmtpClient::senderRejected(int mailID, const QString &address, const QByteArray &msg)
|
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);
|
||||||
|
if (!isPermanentSmtpReply(msg)) {
|
||||||
|
// Transient 4xx on MAIL FROM (e.g. rate limiting): retry with backoff.
|
||||||
|
recordFailed(mailID);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 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;
|
||||||
|
const QString token = pendingIt.value().token;
|
||||||
|
const qint64 rowId = pendingIt.value().rowId;
|
||||||
|
pendingEmails.erase(pendingIt);
|
||||||
|
qCWarning(SmtpClientLog) << "Email to" << userName << "sender rejected - dropping from pending set";
|
||||||
|
emit mailPermanentlyFailed(userName, type, FailureReason::SenderRejected, token, rowId);
|
||||||
|
}
|
||||||
|
mailIdToUser.erase(it);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,26 +1,67 @@
|
||||||
#ifndef SMTPCLIENT_H
|
#ifndef SMTPCLIENT_H
|
||||||
#define SMTPCLIENT_H
|
#define SMTPCLIENT_H
|
||||||
|
|
||||||
|
#include "smtp/qxtmailmessage.h"
|
||||||
|
|
||||||
|
#include <QDateTime>
|
||||||
|
#include <QHash>
|
||||||
#include <QObject>
|
#include <QObject>
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <QTimer>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class QxtSmtp;
|
class QxtSmtp;
|
||||||
class QByteArray;
|
class QByteArray;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @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
|
class SmtpClient : public QObject
|
||||||
{
|
{
|
||||||
Q_OBJECT
|
Q_OBJECT
|
||||||
public:
|
public:
|
||||||
SmtpClient(QObject *parent = 0);
|
SmtpClient(QObject *parent = nullptr);
|
||||||
~SmtpClient();
|
~SmtpClient() override;
|
||||||
|
|
||||||
|
public slots:
|
||||||
|
bool enqueueActivationTokenMail(const QString &nickname, const QString &recipient, const QString &token);
|
||||||
|
bool enqueueForgotPasswordTokenMail(const QString &nickname,
|
||||||
|
const QString &recipient,
|
||||||
|
const QString &token,
|
||||||
|
qint64 rowId = -1);
|
||||||
|
void sendAllEmails();
|
||||||
|
|
||||||
|
signals:
|
||||||
|
void mailDelivered(const QString &userName, EmailType type, const QString &token, qint64 rowId);
|
||||||
|
void mailPermanentlyFailed(const QString &userName,
|
||||||
|
EmailType type,
|
||||||
|
FailureReason reason,
|
||||||
|
const QString &token,
|
||||||
|
qint64 rowId);
|
||||||
|
|
||||||
protected:
|
protected:
|
||||||
QxtSmtp *smtp;
|
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();
|
|
||||||
protected slots:
|
protected slots:
|
||||||
void authenticated();
|
void authenticated();
|
||||||
void authenticationFailed(const QByteArray &msg);
|
void authenticationFailed(const QByteArray &msg);
|
||||||
|
|
@ -30,10 +71,55 @@ protected slots:
|
||||||
void encrypted();
|
void encrypted();
|
||||||
void encryptionFailed(const QByteArray &msg);
|
void encryptionFailed(const QByteArray &msg);
|
||||||
void finished();
|
void finished();
|
||||||
|
void idleTimeout();
|
||||||
void mailFailed(int mailID, int errorCode, const QByteArray &msg);
|
void mailFailed(int mailID, int errorCode, const QByteArray &msg);
|
||||||
void mailSent(int mailID);
|
void mailSent(int mailID);
|
||||||
void recipientRejected(int mailID, const QString &address, const QByteArray &msg);
|
void recipientRejected(int mailID, const QString &address, const QByteArray &msg);
|
||||||
void senderRejected(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;
|
||||||
|
QString token;
|
||||||
|
// Database row id for tables that allow multiple rows per user
|
||||||
|
// ({prefix}_forgot_password); -1 when the name alone identifies the row.
|
||||||
|
qint64 rowId = -1;
|
||||||
|
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,
|
||||||
|
qint64 rowId = -1);
|
||||||
|
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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue