fix: send crash reports and bound email retries (#2680)

Crash reports never reached SMTP: `SendCrashEmail` returned immediately when email was enabled and never called the sender. It now respects the enabled setting, sends the report, and waits up to 30 seconds before the crash handler returns and the process can exit.

The sender uses the `Message-ID` generated by `MimeMessage` and the sender address already added by each caller. This removes the duplicate `Message-ID` header and duplicate address within the `From` header. Gmail rejects duplicate `Message-ID` headers: https://support.google.com/a/answer/13567860.

Email retries use a bounded doubling schedule instead of squaring an integer delay, which could overflow and throw through an `async void` method. The sender returns `Task`, uses `ConfigureAwait(false)` throughout, and logs failures. With the default five attempts and three-second initial delay, waits are 3, 6, 12, and 24 seconds. Each wait is capped at one hour, and there is no wait after the last attempt.

Validation:

- Full Release test suite on the branch updated with `main`: 2,103 passed, 2 intentionally skipped.
- Eight email test cases cover disabled-email early returns, retry timing, low attempt counts, and overflow-prone delays.
- SMTP delivery and the crash-time shutdown path are not covered by an integration test.
This commit is contained in:
Sergi Rosell 2026-10-05 03:41:27 +02:00 • committed by GitHub
parent 6102cdabd7
commit 261ea01ab4
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 122 additions and 16 deletions

View file

@ -6,11 +6,22 @@ using MimeKit;
using Server.Accounting;
using Server.Configurations;
using Server.Engines.Help;
using Server.Logging;
namespace Server.Misc
{
public static class Email
{
private static readonly ILogger _logger = LogFactory.GetLogger(typeof(Email));
// How long SendCrashEmail holds up the exit after a crash: at the default settings, room for the first
// attempts (3 and 6 s apart; a fourth starts at 21 s when they fail fast), and no longer when the SMTP
// server is dead.
private static readonly TimeSpan CrashEmailTimeout = TimeSpan.FromSeconds(30);
// A retry wait stops doubling at an hour: no multi-day waits, and nothing left to overflow.
private const int MaxRetryDelaySeconds = 3600;
/// <summary>
/// Sends Queue-Page request using Email
/// </summary>
@ -69,16 +80,17 @@ namespace Server.Misc
}.ToMessageBody();
}
SendAsync(message);
_ = SendAsync(message);
}
/// <summary>
/// Sends crash email
/// Sends crash email, blocking for up to the crash timeout (30 s) while it is sent: the core exits
/// as soon as the crash handlers return, and a send left running dies with it.
/// </summary>
/// <param name="filePath"></param>
public static void SendCrashEmail(string filePath)
{
if (EmailConfiguration.EmailEnabled)
if (!EmailConfiguration.EmailEnabled)
{
return;
}
@ -94,27 +106,37 @@ namespace Server.Misc
};
builder.Attachments.Add(filePath);
message.Body = builder.ToMessageBody();
try
{
if (!SendAsync(message).Wait(CrashEmailTimeout))
{
_logger.Warning("Crash email not sent after {Timeout}, giving up", CrashEmailTimeout);
}
}
catch (Exception ex)
{
_logger.Error(ex, "Sending crash email failed");
}
}
/// <summary>
/// Sends emails async
/// </summary>
/// <param name="message"></param>
private static async void SendAsync(MimeMessage message)
private static async Task SendAsync(MimeMessage message)
{
if (!EmailConfiguration.EmailEnabled)
{
return;
}
var now = Core.Now;
var messageID = $"<{now:yyyyMMdd}.{now:HHmmssff}@{EmailConfiguration.EmailServer}>";
message.Headers.Add("Message-ID", messageID);
message.From.Add(EmailConfiguration.FromAddress);
var attempts = EmailConfiguration.EmailSendRetryCount;
var retryDelays = RetryDelays(attempts, EmailConfiguration.EmailSendRetryDelay);
Exception lastException = null;
var delay = EmailConfiguration.EmailSendRetryDelay;
for (var i = 0; i < EmailConfiguration.EmailSendRetryCount; i++)
// SendCrashEmail can block the game loop on this task: every await needs ConfigureAwait(false).
for (var i = 0; i < attempts; i++)
{
try
{
@ -130,17 +152,40 @@ namespace Server.Misc
}
catch (Exception ex)
{
lastException = ex;
if (i == 0)
{
Console.WriteLine(ex.Message);
Console.WriteLine(ex.StackTrace);
_logger.Warning(ex, "Sending email {Subject} failed", message.Subject);
}
delay *= delay;
await Task.Delay(delay * 1000).ConfigureAwait(false);
if (i < retryDelays.Length)
{
await Task.Delay(retryDelays[i]).ConfigureAwait(false);
}
}
}
_logger.Error(lastException, "Email {Subject} not sent after {Attempts} attempts", message.Subject, attempts);
}
/// <summary>
/// The waits between send attempts: the configured delay, doubling each time, and none after the last.
/// </summary>
/// <param name="attempts"></param>
/// <param name="firstDelaySeconds"></param>
internal static TimeSpan[] RetryDelays(int attempts, int firstDelaySeconds)
{
var delays = new TimeSpan[Math.Max(attempts, 1) - 1];
var seconds = Math.Clamp(firstDelaySeconds, 0, MaxRetryDelaySeconds);
for (var i = 0; i < delays.Length; i++)
{
delays[i] = TimeSpan.FromSeconds(seconds);
seconds = Math.Min(seconds * 2, MaxRetryDelaySeconds);
}
return delays;
}
}
}