From 2f4590b681a8751c0db4b72af54183fef62c2185 Mon Sep 17 00:00:00 2001 From: Nick Beckley Date: Sat, 6 Jun 2026 19:46:02 +0100 Subject: [PATCH] Phase 4D AuthService and Email Queue --- PlotLine/Controllers/AccountController.cs | 12 +- PlotLine/Data/EmailQueueRepository.cs | 72 ++++++++ PlotLine/Models/EmailQueueModels.cs | 22 +++ PlotLine/Program.cs | 3 + PlotLine/Services/AuthServices.cs | 163 ++++++++++-------- PlotLine/Services/EmailQueueWorker.cs | 79 +++++++++ PlotLine/Sql/039_Phase4D_EmailQueue.sql | 191 ++++++++++++++++++++++ 7 files changed, 465 insertions(+), 77 deletions(-) create mode 100644 PlotLine/Data/EmailQueueRepository.cs create mode 100644 PlotLine/Models/EmailQueueModels.cs create mode 100644 PlotLine/Services/EmailQueueWorker.cs create mode 100644 PlotLine/Sql/039_Phase4D_EmailQueue.sql diff --git a/PlotLine/Controllers/AccountController.cs b/PlotLine/Controllers/AccountController.cs index 7b6215e..439274c 100644 --- a/PlotLine/Controllers/AccountController.cs +++ b/PlotLine/Controllers/AccountController.cs @@ -32,7 +32,7 @@ public sealed class AccountController(IAuthService authService) : Controller } catch { - ModelState.AddModelError(string.Empty, "Your account could not be created because the verification email could not be sent. Please try again later."); + ModelState.AddModelError(string.Empty, "Your account could not be created because the verification email could not be queued. Please try again later."); return View(model); } if (!result.Success) @@ -41,7 +41,7 @@ public sealed class AccountController(IAuthService authService) : Controller return View(model); } - TempData["AuthMessage"] = "Your account has been created. Check your email to verify your address before signing in. If it does not arrive, use the resend verification email link below."; + TempData["AuthMessage"] = "Your account has been created. Please check your email to verify your account. If it does not arrive, use the resend verification email link below."; return RedirectToAction(nameof(Login)); } @@ -149,17 +149,17 @@ public sealed class AccountController(IAuthService authService) : Controller var result = await authService.ForgotPasswordAsync(model); if (!result.Success) { - ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later."); + ModelState.AddModelError(string.Empty, "We could not queue a password reset email right now. Please try again later."); return View(model); } } catch { - ModelState.AddModelError(string.Empty, "We could not send a password reset email right now. Please try again later."); + ModelState.AddModelError(string.Empty, "We could not queue a password reset email right now. Please try again later."); return View(model); } - TempData["AuthMessage"] = "If an account exists for that email address, a password reset link has been sent."; + TempData["AuthMessage"] = "If an account exists for that email address, a password reset email will be sent shortly."; return RedirectToAction(nameof(Login)); } @@ -220,7 +220,7 @@ public sealed class AccountController(IAuthService authService) : Controller return View(model); } - TempData["AuthMessage"] = "If an unverified account exists for that email address, a new verification email has been sent."; + TempData["AuthMessage"] = "If an unverified account exists for that email address, a verification email will be sent shortly."; return View(new ResendVerificationEmailViewModel()); } } diff --git a/PlotLine/Data/EmailQueueRepository.cs b/PlotLine/Data/EmailQueueRepository.cs new file mode 100644 index 0000000..23a570d --- /dev/null +++ b/PlotLine/Data/EmailQueueRepository.cs @@ -0,0 +1,72 @@ +using System.Data; +using Dapper; +using PlotLine.Models; + +namespace PlotLine.Data; + +public interface IEmailQueueRepository +{ + Task CreateAsync(EmailQueueItem email); + Task> DequeueBatchAsync(int batchSize); + Task MarkSentAsync(long emailQueueId); + Task MarkFailedAsync(long emailQueueId, string lastError); + Task ResetStuckSendingAsync(); +} + +public sealed class EmailQueueRepository(ISqlConnectionFactory connectionFactory) : IEmailQueueRepository +{ + public async Task CreateAsync(EmailQueueItem email) + { + using var connection = connectionFactory.CreateConnection(); + return await connection.QuerySingleAsync( + "dbo.EmailQueue_Create", + new + { + email.ToEmail, + email.ToName, + email.FromEmail, + email.FromName, + email.Subject, + email.PlainTextBody, + email.HtmlBody, + email.MaxAttempts + }, + commandType: CommandType.StoredProcedure); + } + + public async Task> DequeueBatchAsync(int batchSize) + { + using var connection = connectionFactory.CreateConnection(); + var rows = await connection.QueryAsync( + "dbo.EmailQueue_DequeueBatch", + new { BatchSize = batchSize }, + commandType: CommandType.StoredProcedure); + return rows.ToList(); + } + + public async Task MarkSentAsync(long emailQueueId) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.EmailQueue_MarkSent", + new { EmailQueueID = emailQueueId }, + commandType: CommandType.StoredProcedure); + } + + public async Task MarkFailedAsync(long emailQueueId, string lastError) + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.EmailQueue_MarkFailed", + new { EmailQueueID = emailQueueId, LastError = lastError }, + commandType: CommandType.StoredProcedure); + } + + public async Task ResetStuckSendingAsync() + { + using var connection = connectionFactory.CreateConnection(); + await connection.ExecuteAsync( + "dbo.EmailQueue_ResetStuckSending", + commandType: CommandType.StoredProcedure); + } +} diff --git a/PlotLine/Models/EmailQueueModels.cs b/PlotLine/Models/EmailQueueModels.cs new file mode 100644 index 0000000..288364a --- /dev/null +++ b/PlotLine/Models/EmailQueueModels.cs @@ -0,0 +1,22 @@ +namespace PlotLine.Models; + +public sealed class EmailQueueItem +{ + public long EmailQueueID { get; set; } + public string ToEmail { get; set; } = string.Empty; + public string? ToName { get; set; } + public string FromEmail { get; set; } = string.Empty; + public string? FromName { get; set; } + public string Subject { get; set; } = string.Empty; + public string PlainTextBody { get; set; } = string.Empty; + public string? HtmlBody { get; set; } + public string Status { get; set; } = "Pending"; + public int AttemptCount { get; set; } + public int MaxAttempts { get; set; } = 5; + public DateTime NextAttemptUtc { get; set; } + public DateTime? LastAttemptUtc { get; set; } + public DateTime? SentUtc { get; set; } + public string? LastError { get; set; } + public DateTime CreatedUtc { get; set; } + public DateTime UpdatedUtc { get; set; } +} diff --git a/PlotLine/Program.cs b/PlotLine/Program.cs index 810aa70..b7e6f40 100644 --- a/PlotLine/Program.cs +++ b/PlotLine/Program.cs @@ -38,6 +38,7 @@ public class Program builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddScoped(); @@ -94,7 +95,9 @@ public class Program builder.Services.AddScoped(); builder.Services.AddScoped(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase.")); builder.Services.AddScoped(); + builder.Services.AddScoped(); builder.Services.AddScoped(); + builder.Services.AddHostedService(); var app = builder.Build(); diff --git a/PlotLine/Services/AuthServices.cs b/PlotLine/Services/AuthServices.cs index 1f22064..c1ec913 100644 --- a/PlotLine/Services/AuthServices.cs +++ b/PlotLine/Services/AuthServices.cs @@ -50,6 +50,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor) public sealed class EmailService( IOptions emailOptions, + IEmailQueueRepository emailQueue, IHttpContextAccessor httpContextAccessor, ILogger logger) : IEmailService { @@ -58,7 +59,7 @@ public sealed class EmailService( public Task SendVerificationEmailAsync(string email, string displayName, Guid token) { var link = BuildAccountUrl("VerifyEmail", token); - var html = BuildTemplate( + var (plainText, html) = BuildTemplate( "Verify your PlotWeaver Studio account", displayName, "Please verify your email address to finish setting up your PlotWeaver Studio account.", @@ -66,13 +67,13 @@ public sealed class EmailService( link, "This link expires in 24 hours."); - return SendAsync(email, displayName, "Verify your PlotWeaver Studio account", html); + return QueueAsync(email, displayName, "Verify your PlotWeaver Studio account", plainText, html); } public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token) { var link = BuildAccountUrl("ResetPassword", token); - var html = BuildTemplate( + var (plainText, html) = BuildTemplate( "Reset your PlotWeaver Studio password", displayName, "Use the link below to choose a new password for your PlotWeaver Studio account.", @@ -80,46 +81,24 @@ public sealed class EmailService( link, "This link expires in 24 hours. If you did not request a reset, you can ignore this email."); - return SendAsync(email, displayName, "Reset your PlotWeaver Studio password", html); + return QueueAsync(email, displayName, "Reset your PlotWeaver Studio password", plainText, html); } - private async Task SendAsync(string toEmail, string displayName, string subject, string html) + private async Task QueueAsync(string toEmail, string displayName, string subject, string plainText, string html) { - if (string.IsNullOrWhiteSpace(_settings.SmtpServer) - || string.IsNullOrWhiteSpace(_settings.Username) - || string.IsNullOrWhiteSpace(_settings.Password) - || string.IsNullOrWhiteSpace(_settings.FromAddress)) + var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem { - logger.LogWarning("Email '{Subject}' for {Email} could not be sent because SMTP settings are incomplete.", subject, toEmail); - throw new InvalidOperationException("Email settings are not configured."); - } + ToEmail = toEmail, + ToName = CleanDisplayName(displayName), + FromEmail = GetFromAddress(), + FromName = CleanDisplayName(_settings.FromName), + Subject = subject, + PlainTextBody = plainText, + HtmlBody = html, + MaxAttempts = 5 + }); - try - { - using var message = new MailMessage - { - From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)), - Subject = subject, - Body = html, - IsBodyHtml = true - }; - message.To.Add(new MailAddress(toEmail, CleanDisplayName(displayName))); - message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(StripHtml(html), null, MediaTypeNames.Text.Plain)); - - using var client = new SmtpClient(_settings.SmtpServer, _settings.Port) - { - EnableSsl = _settings.EnableSsl, - Credentials = new NetworkCredential(_settings.Username, _settings.Password) - }; - - await client.SendMailAsync(message); - logger.LogInformation("Email '{Subject}' sent to {Email}.", subject, toEmail); - } - catch (Exception ex) - { - logger.LogError(ex, "Email '{Subject}' could not be sent to {Email}.", subject, toEmail); - throw; - } + logger.LogInformation("Queued email {EmailQueueID} with subject '{Subject}' for {Email}.", emailQueueId, subject, toEmail); } private string BuildAccountUrl(string action, Guid token) @@ -134,7 +113,7 @@ public sealed class EmailService( return $"https://{host}/Account/{action}?token={Uri.EscapeDataString(token.ToString("D"))}"; } - private static string BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote) + private static (string PlainText, string Html) BuildTemplate(string title, string displayName, string intro, string buttonText, string link, string footnote) { var safeName = WebUtility.HtmlEncode(displayName); var safeTitle = WebUtility.HtmlEncode(title); @@ -143,7 +122,19 @@ public sealed class EmailService( var safeLink = WebUtility.HtmlEncode(link); var safeFootnote = WebUtility.HtmlEncode(footnote); - return $""" + var plainText = $""" + {title} + + Hello {displayName}, + + {intro} + + {buttonText}: {link} + + {footnote} + """; + + var html = $""" @@ -162,17 +153,74 @@ public sealed class EmailService( """; - } - private static string StripHtml(string html) - { - return WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ")); + return (plainText, html); } private static string CleanDisplayName(string value) { return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim(); } + + private string GetFromAddress() + { + return string.IsNullOrWhiteSpace(_settings.FromAddress) + ? "hello@plotweaver.studio" + : _settings.FromAddress.Trim(); + } +} + +public interface IQueuedEmailSender +{ + Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken); +} + +public sealed class QueuedEmailSender( + IOptions emailOptions, + ILogger logger) : IQueuedEmailSender +{ + private readonly EmailSettings _settings = emailOptions.Value; + + public async Task SendAsync(EmailQueueItem email, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(_settings.SmtpServer) + || string.IsNullOrWhiteSpace(_settings.Username) + || string.IsNullOrWhiteSpace(_settings.Password)) + { + throw new InvalidOperationException("SMTP settings are not configured."); + } + + using var message = new MailMessage + { + From = new MailAddress(email.FromEmail, CleanDisplayName(email.FromName)), + Subject = email.Subject, + Body = email.PlainTextBody, + IsBodyHtml = false + }; + message.To.Add(new MailAddress(email.ToEmail, CleanDisplayName(email.ToName))); + message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.PlainTextBody, null, MediaTypeNames.Text.Plain)); + + if (!string.IsNullOrWhiteSpace(email.HtmlBody)) + { + message.AlternateViews.Add(AlternateView.CreateAlternateViewFromString(email.HtmlBody, null, MediaTypeNames.Text.Html)); + } + + using var client = new SmtpClient(_settings.SmtpServer, _settings.Port) + { + EnableSsl = _settings.EnableSsl, + Credentials = new NetworkCredential(_settings.Username, _settings.Password), + Timeout = 30000 + }; + + cancellationToken.ThrowIfCancellationRequested(); + await client.SendMailAsync(message, cancellationToken); + logger.LogInformation("Queued email {EmailQueueID} sent to {Email}.", email.EmailQueueID, email.ToEmail); + } + + private static string CleanDisplayName(string? value) + { + return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim(); + } } public sealed class AuthService( @@ -180,24 +228,16 @@ public sealed class AuthService( IAuthenticationRepository authentication, IPasswordService passwords, IEmailService emails, - IOptions emailOptions, IHttpContextAccessor httpContextAccessor, ILogger logger) : IAuthService { private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24); - private readonly EmailSettings _emailSettings = emailOptions.Value; public async Task RegisterAsync(RegisterViewModel model) { var email = Clean(model.Email); var displayName = Clean(model.DisplayName); - if (!EmailIsConfigured()) - { - logger.LogWarning("Registration could not continue because SMTP settings are incomplete."); - return RegistrationResult.Failed("Email sending is not configured yet. Please try again later."); - } - var existingUser = await users.GetByEmailAsync(email); if (existingUser is not null) { @@ -217,12 +257,6 @@ public sealed class AuthService( public async Task LoginAsync(LoginViewModel model) { var email = Clean(model.Email); - if (!EmailIsConfigured()) - { - logger.LogWarning("Password reset email could not be sent because SMTP settings are incomplete."); - return AuthenticationResult.Failed("Password reset email could not be sent right now."); - } - var user = await users.GetByEmailAsync(email); if (user is null) { @@ -303,12 +337,6 @@ public sealed class AuthService( return AuthenticationResult.Succeeded(); } - if (!EmailIsConfigured()) - { - logger.LogWarning("Verification resend for user {UserID} could not continue because SMTP settings are incomplete.", user.UserID); - return AuthenticationResult.Failed("We could not send the verification email right now. Please try again in a few minutes."); - } - try { var token = Guid.NewGuid(); @@ -415,11 +443,4 @@ public sealed class AuthService( private static string Clean(string value) => value.Trim(); - private bool EmailIsConfigured() - { - return !string.IsNullOrWhiteSpace(_emailSettings.SmtpServer) - && !string.IsNullOrWhiteSpace(_emailSettings.Username) - && !string.IsNullOrWhiteSpace(_emailSettings.Password) - && !string.IsNullOrWhiteSpace(_emailSettings.FromAddress); - } } diff --git a/PlotLine/Services/EmailQueueWorker.cs b/PlotLine/Services/EmailQueueWorker.cs new file mode 100644 index 0000000..e8a6abc --- /dev/null +++ b/PlotLine/Services/EmailQueueWorker.cs @@ -0,0 +1,79 @@ +using PlotLine.Data; + +namespace PlotLine.Services; + +public sealed class EmailQueueWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private const int BatchSize = 10; + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Email queue worker started."); + + await ProcessQueueAsync(stoppingToken); + + using var timer = new PeriodicTimer(PollInterval); + while (await timer.WaitForNextTickAsync(stoppingToken)) + { + await ProcessQueueAsync(stoppingToken); + } + } + + private async Task ProcessQueueAsync(CancellationToken stoppingToken) + { + try + { + using var scope = scopeFactory.CreateScope(); + var queue = scope.ServiceProvider.GetRequiredService(); + var sender = scope.ServiceProvider.GetRequiredService(); + + await queue.ResetStuckSendingAsync(); + var emails = await queue.DequeueBatchAsync(BatchSize); + if (!emails.Any()) + { + return; + } + + logger.LogInformation("Email queue worker dequeued {EmailCount} email(s).", emails.Count); + + foreach (var email in emails) + { + if (stoppingToken.IsCancellationRequested) + { + return; + } + + try + { + await sender.SendAsync(email, stoppingToken); + await queue.MarkSentAsync(email.EmailQueueID); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Queued email {EmailQueueID} failed on attempt {AttemptNumber}.", email.EmailQueueID, email.AttemptCount + 1); + await queue.MarkFailedAsync(email.EmailQueueID, BuildSafeError(ex)); + } + } + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + logger.LogError(ex, "Email queue worker cycle failed."); + } + } + + private static string BuildSafeError(Exception exception) + { + var message = exception.Message; + return message.Length > 2000 ? message[..2000] : message; + } +} diff --git a/PlotLine/Sql/039_Phase4D_EmailQueue.sql b/PlotLine/Sql/039_Phase4D_EmailQueue.sql new file mode 100644 index 0000000..03eafb7 --- /dev/null +++ b/PlotLine/Sql/039_Phase4D_EmailQueue.sql @@ -0,0 +1,191 @@ +SET ANSI_NULLS ON; +GO +SET QUOTED_IDENTIFIER ON; +GO + +IF OBJECT_ID(N'dbo.EmailQueue', N'U') IS NULL +BEGIN + CREATE TABLE dbo.EmailQueue + ( + EmailQueueID bigint IDENTITY(1,1) NOT NULL CONSTRAINT PK_EmailQueue PRIMARY KEY, + ToEmail nvarchar(256) NOT NULL, + ToName nvarchar(200) NULL, + FromEmail nvarchar(256) NOT NULL, + FromName nvarchar(200) NULL, + Subject nvarchar(300) NOT NULL, + PlainTextBody nvarchar(max) NOT NULL, + HtmlBody nvarchar(max) NULL, + Status nvarchar(30) NOT NULL CONSTRAINT DF_EmailQueue_Status DEFAULT N'Pending', + AttemptCount int NOT NULL CONSTRAINT DF_EmailQueue_AttemptCount DEFAULT 0, + MaxAttempts int NOT NULL CONSTRAINT DF_EmailQueue_MaxAttempts DEFAULT 5, + NextAttemptUtc datetime2 NOT NULL CONSTRAINT DF_EmailQueue_NextAttemptUtc DEFAULT SYSUTCDATETIME(), + LastAttemptUtc datetime2 NULL, + SentUtc datetime2 NULL, + LastError nvarchar(max) NULL, + CreatedUtc datetime2 NOT NULL CONSTRAINT DF_EmailQueue_CreatedUtc DEFAULT SYSUTCDATETIME(), + UpdatedUtc datetime2 NOT NULL CONSTRAINT DF_EmailQueue_UpdatedUtc DEFAULT SYSUTCDATETIME(), + CONSTRAINT CK_EmailQueue_Status CHECK (Status IN (N'Pending', N'Sending', N'Sent', N'Failed')) + ); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_EmailQueue_StatusNextAttempt' AND object_id = OBJECT_ID(N'dbo.EmailQueue')) + CREATE INDEX IX_EmailQueue_StatusNextAttempt ON dbo.EmailQueue(Status, NextAttemptUtc) INCLUDE (AttemptCount, MaxAttempts, CreatedUtc); +GO + +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_EmailQueue_CreatedUtc' AND object_id = OBJECT_ID(N'dbo.EmailQueue')) + CREATE INDEX IX_EmailQueue_CreatedUtc ON dbo.EmailQueue(CreatedUtc); +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_Create + @ToEmail nvarchar(256), + @ToName nvarchar(200) = NULL, + @FromEmail nvarchar(256), + @FromName nvarchar(200) = NULL, + @Subject nvarchar(300), + @PlainTextBody nvarchar(max), + @HtmlBody nvarchar(max) = NULL, + @MaxAttempts int = 5 +AS +BEGIN + SET NOCOUNT ON; + + INSERT INTO dbo.EmailQueue + ( + ToEmail, + ToName, + FromEmail, + FromName, + Subject, + PlainTextBody, + HtmlBody, + Status, + AttemptCount, + MaxAttempts, + NextAttemptUtc, + CreatedUtc, + UpdatedUtc + ) + VALUES + ( + @ToEmail, + @ToName, + @FromEmail, + @FromName, + @Subject, + @PlainTextBody, + @HtmlBody, + N'Pending', + 0, + @MaxAttempts, + SYSUTCDATETIME(), + SYSUTCDATETIME(), + SYSUTCDATETIME() + ); + + SELECT CAST(SCOPE_IDENTITY() AS bigint); +END; +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_DequeueBatch + @BatchSize int = 10 +AS +BEGIN + SET NOCOUNT ON; + + IF @BatchSize IS NULL OR @BatchSize <= 0 + SET @BatchSize = 10; + + ;WITH NextEmails AS + ( + SELECT TOP (@BatchSize) * + FROM dbo.EmailQueue WITH (UPDLOCK, READPAST, ROWLOCK) + WHERE Status = N'Pending' + AND NextAttemptUtc <= SYSUTCDATETIME() + AND AttemptCount < MaxAttempts + ORDER BY CreatedUtc, EmailQueueID + ) + UPDATE NextEmails + SET Status = N'Sending', + LastAttemptUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + OUTPUT inserted.EmailQueueID, inserted.ToEmail, inserted.ToName, inserted.FromEmail, inserted.FromName, + inserted.Subject, inserted.PlainTextBody, inserted.HtmlBody, inserted.Status, inserted.AttemptCount, + inserted.MaxAttempts, inserted.NextAttemptUtc, inserted.LastAttemptUtc, inserted.SentUtc, + inserted.LastError, inserted.CreatedUtc, inserted.UpdatedUtc; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_MarkSending + @EmailQueueID bigint +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.EmailQueue + SET Status = N'Sending', + LastAttemptUtc = SYSUTCDATETIME(), + UpdatedUtc = SYSUTCDATETIME() + WHERE EmailQueueID = @EmailQueueID + AND Status = N'Pending' + AND AttemptCount < MaxAttempts; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_MarkSent + @EmailQueueID bigint +AS +BEGIN + SET NOCOUNT ON; + + UPDATE dbo.EmailQueue + SET Status = N'Sent', + SentUtc = SYSUTCDATETIME(), + LastError = NULL, + UpdatedUtc = SYSUTCDATETIME() + WHERE EmailQueueID = @EmailQueueID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_MarkFailed + @EmailQueueID bigint, + @LastError nvarchar(max) +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @Now datetime2 = SYSUTCDATETIME(); + + UPDATE dbo.EmailQueue + SET AttemptCount = AttemptCount + 1, + LastAttemptUtc = @Now, + LastError = @LastError, + Status = CASE WHEN AttemptCount + 1 >= MaxAttempts THEN N'Failed' ELSE N'Pending' END, + NextAttemptUtc = CASE + WHEN AttemptCount + 1 >= MaxAttempts THEN NextAttemptUtc + WHEN AttemptCount + 1 = 1 THEN DATEADD(minute, 1, @Now) + WHEN AttemptCount + 1 = 2 THEN DATEADD(minute, 5, @Now) + ELSE DATEADD(minute, 15, @Now) + END, + UpdatedUtc = @Now + WHERE EmailQueueID = @EmailQueueID; +END; +GO + +CREATE OR ALTER PROCEDURE dbo.EmailQueue_ResetStuckSending +AS +BEGIN + SET NOCOUNT ON; + + DECLARE @Now datetime2 = SYSUTCDATETIME(); + DECLARE @Cutoff datetime2 = DATEADD(minute, -10, @Now); + + UPDATE dbo.EmailQueue + SET Status = CASE WHEN AttemptCount >= MaxAttempts THEN N'Failed' ELSE N'Pending' END, + NextAttemptUtc = CASE WHEN AttemptCount >= MaxAttempts THEN NextAttemptUtc ELSE @Now END, + UpdatedUtc = @Now + WHERE Status = N'Sending' + AND LastAttemptUtc IS NOT NULL + AND LastAttemptUtc < @Cutoff; +END; +GO