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
|
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);
|
return View(model);
|
||||||
}
|
}
|
||||||
if (!result.Success)
|
if (!result.Success)
|
||||||
@ -41,7 +41,7 @@ public sealed class AccountController(IAuthService authService) : Controller
|
|||||||
return View(model);
|
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));
|
return RedirectToAction(nameof(Login));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -149,17 +149,17 @@ public sealed class AccountController(IAuthService authService) : Controller
|
|||||||
var result = await authService.ForgotPasswordAsync(model);
|
var result = await authService.ForgotPasswordAsync(model);
|
||||||
if (!result.Success)
|
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);
|
return View(model);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
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);
|
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));
|
return RedirectToAction(nameof(Login));
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -220,7 +220,7 @@ public sealed class AccountController(IAuthService authService) : Controller
|
|||||||
return View(model);
|
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());
|
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.AddSingleton<ISqlConnectionFactory, SqlConnectionFactory>();
|
||||||
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
builder.Services.AddScoped<IUserRepository, UserRepository>();
|
||||||
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
|
builder.Services.AddScoped<IAuthenticationRepository, AuthenticationRepository>();
|
||||||
|
builder.Services.AddScoped<IEmailQueueRepository, EmailQueueRepository>();
|
||||||
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
builder.Services.AddScoped<IProjectRepository, ProjectRepository>();
|
||||||
builder.Services.AddScoped<IBookRepository, BookRepository>();
|
builder.Services.AddScoped<IBookRepository, BookRepository>();
|
||||||
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
|
builder.Services.AddScoped<IChapterRepository, ChapterRepository>();
|
||||||
@ -94,7 +95,9 @@ public class Program
|
|||||||
builder.Services.AddScoped<IPasswordService, PasswordService>();
|
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<ITwoFactorService>(_ => throw new NotImplementedException("Two-factor authentication will be implemented in a later phase."));
|
||||||
builder.Services.AddScoped<IEmailService, EmailService>();
|
builder.Services.AddScoped<IEmailService, EmailService>();
|
||||||
|
builder.Services.AddScoped<IQueuedEmailSender, QueuedEmailSender>();
|
||||||
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();
|
||||||
|
builder.Services.AddHostedService<EmailQueueWorker>();
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
|
|||||||
@ -50,6 +50,7 @@ public sealed class CurrentUserService(IHttpContextAccessor httpContextAccessor)
|
|||||||
|
|
||||||
public sealed class EmailService(
|
public sealed class EmailService(
|
||||||
IOptions<EmailSettings> emailOptions,
|
IOptions<EmailSettings> emailOptions,
|
||||||
|
IEmailQueueRepository emailQueue,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<EmailService> logger) : IEmailService
|
ILogger<EmailService> logger) : IEmailService
|
||||||
{
|
{
|
||||||
@ -58,7 +59,7 @@ public sealed class EmailService(
|
|||||||
public Task SendVerificationEmailAsync(string email, string displayName, Guid token)
|
public Task SendVerificationEmailAsync(string email, string displayName, Guid token)
|
||||||
{
|
{
|
||||||
var link = BuildAccountUrl("VerifyEmail", token);
|
var link = BuildAccountUrl("VerifyEmail", token);
|
||||||
var html = BuildTemplate(
|
var (plainText, html) = BuildTemplate(
|
||||||
"Verify your PlotWeaver Studio account",
|
"Verify your PlotWeaver Studio account",
|
||||||
displayName,
|
displayName,
|
||||||
"Please verify your email address to finish setting up your PlotWeaver Studio account.",
|
"Please verify your email address to finish setting up your PlotWeaver Studio account.",
|
||||||
@ -66,13 +67,13 @@ public sealed class EmailService(
|
|||||||
link,
|
link,
|
||||||
"This link expires in 24 hours.");
|
"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)
|
public Task SendPasswordResetEmailAsync(string email, string displayName, Guid token)
|
||||||
{
|
{
|
||||||
var link = BuildAccountUrl("ResetPassword", token);
|
var link = BuildAccountUrl("ResetPassword", token);
|
||||||
var html = BuildTemplate(
|
var (plainText, html) = BuildTemplate(
|
||||||
"Reset your PlotWeaver Studio password",
|
"Reset your PlotWeaver Studio password",
|
||||||
displayName,
|
displayName,
|
||||||
"Use the link below to choose a new password for your PlotWeaver Studio account.",
|
"Use the link below to choose a new password for your PlotWeaver Studio account.",
|
||||||
@ -80,46 +81,24 @@ public sealed class EmailService(
|
|||||||
link,
|
link,
|
||||||
"This link expires in 24 hours. If you did not request a reset, you can ignore this email.");
|
"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)
|
var emailQueueId = await emailQueue.CreateAsync(new EmailQueueItem
|
||||||
|| string.IsNullOrWhiteSpace(_settings.Username)
|
|
||||||
|| string.IsNullOrWhiteSpace(_settings.Password)
|
|
||||||
|| string.IsNullOrWhiteSpace(_settings.FromAddress))
|
|
||||||
{
|
{
|
||||||
logger.LogWarning("Email '{Subject}' for {Email} could not be sent because SMTP settings are incomplete.", subject, toEmail);
|
ToEmail = toEmail,
|
||||||
throw new InvalidOperationException("Email settings are not configured.");
|
ToName = CleanDisplayName(displayName),
|
||||||
}
|
FromEmail = GetFromAddress(),
|
||||||
|
FromName = CleanDisplayName(_settings.FromName),
|
||||||
try
|
|
||||||
{
|
|
||||||
using var message = new MailMessage
|
|
||||||
{
|
|
||||||
From = new MailAddress(_settings.FromAddress, CleanDisplayName(_settings.FromName)),
|
|
||||||
Subject = subject,
|
Subject = subject,
|
||||||
Body = html,
|
PlainTextBody = plainText,
|
||||||
IsBodyHtml = true
|
HtmlBody = html,
|
||||||
};
|
MaxAttempts = 5
|
||||||
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)
|
logger.LogInformation("Queued email {EmailQueueID} with subject '{Subject}' for {Email}.", emailQueueId, subject, toEmail);
|
||||||
{
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string BuildAccountUrl(string action, Guid token)
|
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"))}";
|
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 safeName = WebUtility.HtmlEncode(displayName);
|
||||||
var safeTitle = WebUtility.HtmlEncode(title);
|
var safeTitle = WebUtility.HtmlEncode(title);
|
||||||
@ -143,7 +122,19 @@ public sealed class EmailService(
|
|||||||
var safeLink = WebUtility.HtmlEncode(link);
|
var safeLink = WebUtility.HtmlEncode(link);
|
||||||
var safeFootnote = WebUtility.HtmlEncode(footnote);
|
var safeFootnote = WebUtility.HtmlEncode(footnote);
|
||||||
|
|
||||||
return $"""
|
var plainText = $"""
|
||||||
|
{title}
|
||||||
|
|
||||||
|
Hello {displayName},
|
||||||
|
|
||||||
|
{intro}
|
||||||
|
|
||||||
|
{buttonText}: {link}
|
||||||
|
|
||||||
|
{footnote}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var html = $"""
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<body style="font-family:Segoe UI,Arial,sans-serif;background:#f7f1e8;margin:0;padding:32px;color:#251c17;">
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
""";
|
""";
|
||||||
}
|
|
||||||
|
|
||||||
private static string StripHtml(string html)
|
return (plainText, html);
|
||||||
{
|
|
||||||
return WebUtility.HtmlDecode(System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " "));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string CleanDisplayName(string value)
|
private static string CleanDisplayName(string value)
|
||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value) ? "PlotWeaver Studio" : value.Trim();
|
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(
|
public sealed class AuthService(
|
||||||
@ -180,24 +228,16 @@ public sealed class AuthService(
|
|||||||
IAuthenticationRepository authentication,
|
IAuthenticationRepository authentication,
|
||||||
IPasswordService passwords,
|
IPasswordService passwords,
|
||||||
IEmailService emails,
|
IEmailService emails,
|
||||||
IOptions<EmailSettings> emailOptions,
|
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
ILogger<AuthService> logger) : IAuthService
|
ILogger<AuthService> logger) : IAuthService
|
||||||
{
|
{
|
||||||
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
private static readonly TimeSpan TokenLifetime = TimeSpan.FromHours(24);
|
||||||
private readonly EmailSettings _emailSettings = emailOptions.Value;
|
|
||||||
|
|
||||||
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
|
public async Task<RegistrationResult> RegisterAsync(RegisterViewModel model)
|
||||||
{
|
{
|
||||||
var email = Clean(model.Email);
|
var email = Clean(model.Email);
|
||||||
var displayName = Clean(model.DisplayName);
|
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);
|
var existingUser = await users.GetByEmailAsync(email);
|
||||||
if (existingUser is not null)
|
if (existingUser is not null)
|
||||||
{
|
{
|
||||||
@ -217,12 +257,6 @@ public sealed class AuthService(
|
|||||||
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
|
public async Task<AuthenticationResult> LoginAsync(LoginViewModel model)
|
||||||
{
|
{
|
||||||
var email = Clean(model.Email);
|
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);
|
var user = await users.GetByEmailAsync(email);
|
||||||
if (user is null)
|
if (user is null)
|
||||||
{
|
{
|
||||||
@ -303,12 +337,6 @@ public sealed class AuthService(
|
|||||||
return AuthenticationResult.Succeeded();
|
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
|
try
|
||||||
{
|
{
|
||||||
var token = Guid.NewGuid();
|
var token = Guid.NewGuid();
|
||||||
@ -415,11 +443,4 @@ public sealed class AuthService(
|
|||||||
|
|
||||||
private static string Clean(string value) => value.Trim();
|
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