Phase 4D AuthService and Email Queue

This commit is contained in:
Nick Beckley 2026-06-06 19:46:02 +01:00
parent 44416ec445
commit 2f4590b681
7 changed files with 465 additions and 77 deletions

View File

@ -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());
}
}

View File

@ -0,0 +1,72 @@
using System.Data;
using Dapper;
using PlotLine.Models;
namespace PlotLine.Data;
public interface IEmailQueueRepository
{
Task<long> CreateAsync(EmailQueueItem email);
Task<IReadOnlyList<EmailQueueItem>> 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<long> CreateAsync(EmailQueueItem email)
{
using var connection = connectionFactory.CreateConnection();
return await connection.QuerySingleAsync<long>(
"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<IReadOnlyList<EmailQueueItem>> DequeueBatchAsync(int batchSize)
{
using var connection = connectionFactory.CreateConnection();
var rows = await connection.QueryAsync<EmailQueueItem>(
"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);
}
}

View File

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

View File

@ -38,6 +38,7 @@ public class Program
builder.Services.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
builder.Services.AddScoped<IUserRepository, UserRepository>();
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
builder.Services.AddScoped<IBookRepository, BookRepository>();
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
@ -94,7 +95,9 @@ public class Program
builder.Services.AddScoped<IPasswordService, PasswordService>();
builder.Services.AddScoped<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
builder.Services.AddScoped<IEmailService, EmailService>();
builder.Services.AddScoped<IQueuedEmailSender, QueuedEmailSender>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
builder.Services.AddHostedService<EmailQueueWorker>();
var app = builder.Build();

View File

@ -50,6 +50,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
public sealed class EmailService(
IOptions<EmailSettings> emailOptions,
IEmailQueueRepository emailQueue,
IHttpContextAccessor httpContextAccessor,
ILogger<EmailService> 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.");
}
try
{
using var message = new MailMessage
{
From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)),
ToEmail = toEmail,
ToName = CleanDisplayName(displayName),
FromEmail = GetFromAddress(),
FromName = 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));
PlainTextBody = plainText,
HtmlBody = html,
MaxAttempts = 5
});
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 = $"""
<!doctype html>
<html>
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
@ -162,17 +153,74 @@ public sealed class EmailService(
</body>
</html>
""";
}
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<EmailSettings> emailOptions,
ILogger<QueuedEmailSender> 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<EmailSettings> emailOptions,
IHttpContextAccessor httpContextAccessor,
ILogger<AuthService> logger) : IAuthService
{
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
private readonly EmailSettings _emailSettings = emailOptions.Value;
public async Task<RegistrationResult> 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<AuthenticationResult> 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);
}
}

View File

@ -0,0 +1,79 @@
using PlotLine.Data;
namespace PlotLine.Services;
public sealed class EmailQueueWorker(
IServiceScopeFactory scopeFactory,
ILogger<EmailQueueWorker> 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<IEmailQueueRepository>();
var sender = scope.ServiceProvider.GetRequiredService<IQueuedEmailSender>();
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;
}
}

View File

@ -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