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;
}
}
}