diff --git a/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs new file mode 100644 index 000000000..de6825c9f --- /dev/null +++ b/Projects/UOContent.Tests/Tests/Misc/EmailTests.cs @@ -0,0 +1,61 @@ +using System; +using System.IO; +using Server.Configurations; +using Server.Misc; +using Xunit; + +namespace UOContent.Tests; + +[Collection("Sequential UOContent Tests")] +public class EmailTests +{ + [Fact] + public void SendCrashEmailDoesNothingWhileEmailIsDisabled() + { + Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); + + // Missing input must be ignored while email is disabled. + var report = Path.Combine(Path.GetTempPath(), $"crash-email-test-{Guid.NewGuid():N}", "Crash.log"); + + Email.SendCrashEmail(report); + } + + [Fact] + public void SendQueueEmailDoesNothingWhileEmailIsDisabled() + { + Assert.False(EmailConfiguration.EmailEnabled, "The test host never enables email"); + + Email.SendQueueEmail(null, "Other"); + } + + [Fact] + public void RetryWaitsDoubleFromTheConfiguredDelayAndStopAtTheLastAttempt() + { + TimeSpan[] expected = + [ + TimeSpan.FromSeconds(3), TimeSpan.FromSeconds(6), TimeSpan.FromSeconds(12), TimeSpan.FromSeconds(24) + ]; + + Assert.Equal(expected, Email.RetryDelays(5, 3)); + } + + [Theory] + [InlineData(int.MinValue)] + [InlineData(0)] + [InlineData(1)] + public void FewerThanTwoAttemptsNeverWait(int attempts) + { + Assert.Empty(Email.RetryDelays(attempts, 3)); + } + + [Theory] + [InlineData(8)] + [InlineData(int.MaxValue)] + public void RetryWaitsNeverOverflowAndStopDoublingAtAnHour(int firstDelaySeconds) + { + var delays = Email.RetryDelays(64, firstDelaySeconds); + + Assert.All(delays, delay => Assert.InRange(delay, TimeSpan.Zero, TimeSpan.FromHours(1))); + Assert.Equal(TimeSpan.FromHours(1), delays[^1]); + } +} diff --git a/Projects/UOContent/Misc/Email.cs b/Projects/UOContent/Misc/Email.cs index 5e30ed30a..4b8f89661 100644 --- a/Projects/UOContent/Misc/Email.cs +++ b/Projects/UOContent/Misc/Email.cs @@ -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; + /// /// Sends Queue-Page request using Email /// @@ -69,16 +80,17 @@ namespace Server.Misc }.ToMessageBody(); } - SendAsync(message); + _ = SendAsync(message); } /// - /// 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. /// /// 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"); + } } /// /// Sends emails async /// /// - 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); + } + + /// + /// The waits between send attempts: the configured delay, doubling each time, and none after the last. + /// + /// + /// + 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; } } }