Phase 4D AuthService and Email Queue
This commit is contained in:
parent
44416ec445
commit
2f4590b681
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
72
PlotLine/Data/EmailQueueRepository.cs
Normal file
72
PlotLine/Data/EmailQueueRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
22
PlotLine/Models/EmailQueueModels.cs
Normal file
22
PlotLine/Models/EmailQueueModels.cs
Normal 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; }
|
||||
}
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
79
PlotLine/Services/EmailQueueWorker.cs
Normal file
79
PlotLine/Services/EmailQueueWorker.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
191
PlotLine/Sql/039_Phase4D_EmailQueue.sql
Normal file
191
PlotLine/Sql/039_Phase4D_EmailQueue.sql
Normal 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
|
||||
Loading…
x
Reference in New Issue
Block a user