diff --git a/API.IntegrationTests/Tests/EmailQueueTests.cs b/API.IntegrationTests/Tests/EmailQueueTests.cs new file mode 100644 index 00000000..2e7b3d25 --- /dev/null +++ b/API.IntegrationTests/Tests/EmailQueueTests.cs @@ -0,0 +1,298 @@ +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using OpenShock.API.IntegrationTests.Helpers; +using OpenShock.API.Services.Email.Queue; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email; +using OpenShock.Common.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Queue; + +namespace OpenShock.API.IntegrationTests.Tests; + +/// +/// Tests the email retry queue: the queue-on-failure decorator and the processor the Cron job runs. +/// Both are exercised directly with a controllable fake against the real +/// test database, so no Mailpit / Cron host is needed. Serialized because the processor drains every +/// due row, which would otherwise race with sibling tests in this class. +/// +[NotInParallel] +public sealed class EmailQueueTests +{ + [ClassDataSource(Shared = SharedType.PerTestSession)] + public required WebApplicationFactory WebApplicationFactory { get; init; } + + private const string Password = "SecurePassword123#"; + + /// Fake transport: records sends and can be told to fail with a chosen exception. + private sealed class FakeEmailSender : IEmailSender + { + public sealed record SentEmail(string Kind, Contact To, Uri? Link, string? NewEmail); + + public List Sent { get; } = []; + + /// When set, every send throws this instead of recording. + public Func? Throw { get; set; } + + private Task Handle(string kind, Contact to, Uri? link, string? newEmail) + { + if (Throw is not null) throw Throw(); + Sent.Add(new SentEmail(kind, to, link, newEmail)); + return Task.CompletedTask; + } + + public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) + => Handle("activate", to, activationLink, null); + public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) + => Handle("reset", to, resetLink, null); + public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) + => Handle("verify", to, verificationLink, null); + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) + => Handle("notice", to, null, newEmail); + } + + private IDbContextFactory DbFactory + => WebApplicationFactory.Services.GetRequiredService>(); + + private QueueingEmailService CreateDecorator(IEmailSender sender, EmailQueueOptions? options = null) + => new(sender, DbFactory, options ?? new EmailQueueOptions(), NullLogger.Instance); + + private EmailQueueProcessor CreateProcessor(IEmailSender sender, EmailQueueOptions? options = null) + => new(DbFactory, sender, + WebApplicationFactory.Services.GetRequiredService(), + options ?? new EmailQueueOptions(), + NullLogger.Instance); + + private async Task> QueuedRowsForEmailAsync(string email) + { + await using var db = await DbFactory.CreateDbContextAsync(); + var rows = await db.QueuedEmails.ToListAsync(); + return rows.Where(r => r.Payload.RootElement.TryGetProperty("Email", out var e) + && e.GetString() == email).ToList(); + } + + private static string ExtractToken(Uri uri) + { + foreach (var part in uri.Query.TrimStart('?').Split('&')) + { + var kv = part.Split('=', 2); + if (kv.Length == 2 && kv[0] == "token") return Uri.UnescapeDataString(kv[1]); + } + throw new InvalidOperationException($"No token query param in {uri}"); + } + + // --- Decorator: enqueue-on-failure --- + + [Test] + public async Task Decorator_ActivateAccount_TransientFailure_EnqueuesTokenlessRow() + { + var email = TestHelper.UniqueEmail("queue-enqueue"); + var userId = Guid.CreateVersion7(); + var sender = new FakeEmailSender { Throw = () => new EmailDeliveryException(isTransient: true, "boom") }; + var decorator = CreateDecorator(sender); + + await decorator.ActivateAccount(userId, new Contact(email, "tester"), + new Uri("https://openshock.app/activate?token=super-secret-token")); + + var rows = await QueuedRowsForEmailAsync(email); + await Assert.That(rows.Count).IsEqualTo(1); + var row = rows[0]; + await Assert.That(row.Type).IsEqualTo(QueuedEmailType.AccountActivation); + await Assert.That(row.Attempts).IsEqualTo(1); + await Assert.That(row.NextAttemptAt).IsGreaterThan(DateTime.UtcNow); + await Assert.That(row.Payload.RootElement.GetProperty("UserId").GetGuid()).IsEqualTo(userId); + // The token must never be persisted in the queue. + await Assert.That(row.Payload.RootElement.GetRawText()).DoesNotContain("super-secret-token"); + } + + [Test] + public async Task Decorator_ActivateAccount_PermanentFailure_DoesNotEnqueue() + { + var email = TestHelper.UniqueEmail("queue-permanent"); + var sender = new FakeEmailSender { Throw = () => new EmailDeliveryException(isTransient: false, "bad request") }; + var decorator = CreateDecorator(sender); + + await decorator.ActivateAccount(Guid.CreateVersion7(), new Contact(email, "tester"), + new Uri("https://openshock.app/activate?token=x")); + + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + } + + [Test] + public async Task Decorator_PasswordReset_TransientFailure_NotEnqueued() + { + var email = TestHelper.UniqueEmail("queue-reset"); + var sender = new FakeEmailSender { Throw = () => new EmailDeliveryException(isTransient: true, "boom") }; + var decorator = CreateDecorator(sender); + + // Password reset is excluded from the queue — a failure must not throw and must not enqueue. + await decorator.PasswordReset(new Contact(email, "tester"), + new Uri("https://openshock.app/#/account/password/recover/x/y")); + + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + } + + // --- Processor: regenerate + send --- + + [Test] + public async Task Processor_Activation_RegeneratesToken_SendsAndActivates() + { + var email = TestHelper.UniqueEmail("queue-proc-activate"); + var username = TestHelper.UniqueUsername("queueprocactivate"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, Password, activated: false); + await EnqueueAsync(QueuedEmailType.AccountActivation, new QueuedEmailPayloads.Activation(userId, email)); + + var sender = new FakeEmailSender(); + await CreateProcessor(sender).ProcessDueItemsAsync(CancellationToken.None); + + // The processor minted a fresh token and sent the activation email. + await Assert.That(sender.Sent.Count).IsEqualTo(1); + var sent = sender.Sent[0]; + await Assert.That(sent.Kind).IsEqualTo("activate"); + await Assert.That(sent.To.Email).IsEqualTo(email); + + // The row is gone, and the regenerated token actually activates the account. + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + + var token = ExtractToken(sent.Link!); + using var client = WebApplicationFactory.CreateClient(); + var activateResponse = await client.PostAsync($"/1/account/activate?token={token}", null); + await Assert.That(activateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var db = await DbFactory.CreateDbContextAsync(); + var user = await db.Users.FirstAsync(u => u.Id == userId); + await Assert.That(user.ActivatedAt).IsNotNull(); + } + + [Test] + public async Task Processor_Activation_AlreadyActivated_DropsWithoutSending() + { + var email = TestHelper.UniqueEmail("queue-proc-activated"); + var username = TestHelper.UniqueUsername("queueprocactivated"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, Password, activated: true); + await EnqueueAsync(QueuedEmailType.AccountActivation, new QueuedEmailPayloads.Activation(userId, email)); + + var sender = new FakeEmailSender(); + await CreateProcessor(sender).ProcessDueItemsAsync(CancellationToken.None); + + await Assert.That(sender.Sent.Count).IsEqualTo(0); + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + } + + [Test] + public async Task Processor_TransientFailure_ReschedulesWithBackoff() + { + var email = TestHelper.UniqueEmail("queue-proc-retry"); + var username = TestHelper.UniqueUsername("queueprocretry"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, Password, activated: false); + await EnqueueAsync(QueuedEmailType.AccountActivation, new QueuedEmailPayloads.Activation(userId, email)); + + var sender = new FakeEmailSender { Throw = () => new EmailDeliveryException(isTransient: true, "still down") }; + await CreateProcessor(sender).ProcessDueItemsAsync(CancellationToken.None); + + var rows = await QueuedRowsForEmailAsync(email); + await Assert.That(rows.Count).IsEqualTo(1); + await Assert.That(rows[0].Attempts).IsEqualTo(2); + await Assert.That(rows[0].NextAttemptAt).IsGreaterThan(DateTime.UtcNow); + await Assert.That(rows[0].LastError).IsNotNull(); + } + + [Test] + public async Task Processor_ExhaustsAttempts_DropsRow() + { + var email = TestHelper.UniqueEmail("queue-proc-giveup"); + var username = TestHelper.UniqueUsername("queueprocgiveup"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, Password, activated: false); + await EnqueueAsync(QueuedEmailType.AccountActivation, new QueuedEmailPayloads.Activation(userId, email)); + + // MaxAttempts = 2: the row is already on attempt 1, so this attempt (2) is the last. + var options = new EmailQueueOptions { MaxAttempts = 2 }; + var sender = new FakeEmailSender { Throw = () => new EmailDeliveryException(isTransient: true, "down") }; + await CreateProcessor(sender, options).ProcessDueItemsAsync(CancellationToken.None); + + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + } + + [Test] + public async Task Processor_EmailChangeNotice_Resends() + { + var email = TestHelper.UniqueEmail("queue-proc-notice"); + var newEmail = TestHelper.UniqueEmail("queue-proc-notice-new"); + var username = TestHelper.UniqueUsername("queueprocnotice"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, Password, activated: true); + await EnqueueAsync(QueuedEmailType.EmailChangeNotice, new QueuedEmailPayloads.EmailChangeNotice(userId, email, newEmail)); + + var sender = new FakeEmailSender(); + await CreateProcessor(sender).ProcessDueItemsAsync(CancellationToken.None); + + await Assert.That(sender.Sent.Count).IsEqualTo(1); + await Assert.That(sender.Sent[0].Kind).IsEqualTo("notice"); + await Assert.That(sender.Sent[0].To.Email).IsEqualTo(email); + await Assert.That(sender.Sent[0].NewEmail).IsEqualTo(newEmail); + await Assert.That((await QueuedRowsForEmailAsync(email)).Count).IsEqualTo(0); + } + + [Test] + public async Task Processor_EmailVerification_RegeneratesToken_VerifiesChange() + { + var oldEmail = TestHelper.UniqueEmail("queue-proc-verify-old"); + var newEmail = TestHelper.UniqueEmail("queue-proc-verify-new"); + var username = TestHelper.UniqueUsername("queueprocverify"); + var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, oldEmail, Password, activated: true); + + // Pending email change (with some stale token hash the processor will rotate). + await using (var db = await DbFactory.CreateDbContextAsync()) + { + var user = await db.Users.FirstAsync(u => u.Id == userId); + db.UserEmailChanges.Add(new UserEmailChange + { + Id = Guid.CreateVersion7(), + UserId = userId, + OldEmail = oldEmail, + NewEmail = newEmail, + TokenHash = OpenShock.Common.Utils.HashingUtils.HashToken("stale-token"), + SecurityStampAtCreate = user.SecurityStamp + }); + await db.SaveChangesAsync(); + } + + await EnqueueAsync(QueuedEmailType.EmailVerification, new QueuedEmailPayloads.EmailVerification(userId, newEmail)); + + var sender = new FakeEmailSender(); + await CreateProcessor(sender).ProcessDueItemsAsync(CancellationToken.None); + + await Assert.That(sender.Sent.Count).IsEqualTo(1); + var sent = sender.Sent[0]; + await Assert.That(sent.Kind).IsEqualTo("verify"); + await Assert.That(sent.To.Email).IsEqualTo(newEmail); + await Assert.That((await QueuedRowsForEmailAsync(newEmail)).Count).IsEqualTo(0); + + // The freshly minted token completes the email change. + var token = ExtractToken(sent.Link!); + using var client = WebApplicationFactory.CreateClient(); + var verifyResponse = await client.PostAsync($"/1/account/email-change/verify?token={token}", null); + await Assert.That(verifyResponse.StatusCode).IsEqualTo(HttpStatusCode.OK); + + await using var verifyDb = await DbFactory.CreateDbContextAsync(); + var updated = await verifyDb.Users.FirstAsync(u => u.Id == userId); + await Assert.That(updated.Email).IsEqualTo(newEmail); + } + + private async Task EnqueueAsync(QueuedEmailType type, object payload) + { + await using var db = await DbFactory.CreateDbContextAsync(); + db.QueuedEmails.Add(new QueuedEmail + { + Id = Guid.CreateVersion7(), + Type = type, + Payload = JsonSerializer.SerializeToDocument(payload), + Attempts = 1, + NextAttemptAt = DateTime.UtcNow.AddMinutes(-1) // already due + }); + await db.SaveChangesAsync(); + } +} diff --git a/API/API.csproj b/API/API.csproj index c61e25fb..6b49e1ac 100644 --- a/API/API.csproj +++ b/API/API.csproj @@ -12,8 +12,6 @@ - - @@ -24,15 +22,6 @@ - - - - PreserveNewest - PreserveNewest - - - diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs index 398df273..b19feddd 100644 --- a/API/Services/Account/AccountService.cs +++ b/API/Services/Account/AccountService.cs @@ -4,7 +4,7 @@ using OneOf; using OneOf.Types; using OpenShock.API.Services.Email; -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Mailjet.Mail; using OpenShock.Common.Constants; using OpenShock.Common.Models; using OpenShock.Common.OpenShockDb; @@ -117,7 +117,7 @@ public async Task, AccountWithEmailOrUsernameExists>> Create await _db.SaveChangesAsync(); - await _emailService.ActivateAccount(new Contact(email, username), + await _emailService.ActivateAccount(user.Id, new Contact(email, username), new Uri(_frontendConfig.BaseUrl, $"/activate?token={token}")); return new Success(user); } @@ -201,6 +201,7 @@ public async Task, AccountWithEmailOrUsernameExists>> Create if (!isEmailTrusted && activationToken is not null) { await _emailService.ActivateAccount( + user.Id, new Contact(email, username), new Uri(_frontendConfig.BaseUrl, $"/activate?token={activationToken}") ); @@ -594,7 +595,7 @@ public async Task AddEmailService(this WebApplicationBuilder builder) { - var mailOptions = builder.Configuration.GetRequiredSection(MailOptions.SectionName).Get() ?? throw new NullReferenceException(); - - if (mailOptions.Type == MailOptions.MailType.None) - { - builder.Services.AddSingleton(); // Add a dummy email service - return builder; - } + // Shared raw sender + email configuration (Mailjet / SMTP / none). + await builder.AddOpenShockEmailSender(); - // Add sender contact configuration - builder.AddSenderContactConfiguration(); - await builder.AddEmailServiceTemplates(); - - switch (mailOptions.Type) - { - case MailOptions.MailType.Mailjet: - builder.AddMailjetEmailService(); - break; - case MailOptions.MailType.Smtp: - builder.AddSmtpEmailService(); - break; - default: - throw new Exception("Unknown mail type"); - } + // Application-facing send path: send now, queue on transient failure. The Cron project owns + // the worker that drains the queue, so no hosted service is registered here. + builder.Services.AddScoped(); return builder; } - - private static WebApplicationBuilder AddSenderContactConfiguration(this WebApplicationBuilder builder) - { - builder.Services.AddSingleton(builder.Configuration.GetRequiredSection(MailOptions.SenderSectionName).Get() ?? throw new NullReferenceException()); - return builder; - } - - private static async Task AddEmailServiceTemplates(this WebApplicationBuilder builder) - { - var accountActivation = EmailTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid"); - var passwordReset = EmailTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid"); - var emailVerification = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid"); - var emailChangeNotice = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid"); - - await Task.WhenAll(accountActivation, passwordReset, emailVerification, emailChangeNotice); - - builder.Services.AddSingleton(new EmailServiceTemplates - { - AccountActivation = await accountActivation, - PasswordReset = await passwordReset, - EmailVerification = await emailVerification, - EmailChangeNotice = await emailChangeNotice, - }); - return builder; - } } diff --git a/API/Services/Email/IEmailService.cs b/API/Services/Email/IEmailService.cs index 12aee102..3db4455c 100644 --- a/API/Services/Email/IEmailService.cs +++ b/API/Services/Email/IEmailService.cs @@ -1,43 +1,51 @@ -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email; +using OpenShock.Common.Services.Email.Mailjet.Mail; namespace OpenShock.API.Services.Email; +/// +/// Application-facing email service. Sends now via the underlying and, when +/// the upstream provider fails transiently, queues the email for a later retry instead of throwing. +/// +/// Queueable methods take the target userId so the retry worker can look the account up and +/// regenerate a fresh token before resending — tokens are never persisted in the queue. +/// public interface IEmailService { /// - /// When a user uses the signup form we send this email to let them activate their account + /// When a user uses the signup form we send this email to let them activate their account. /// + /// The id of the account being activated (used to regenerate the link on retry). /// /// /// - /// - public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default); - + public Task ActivateAccount(Guid userId, Contact to, Uri activationLink, CancellationToken cancellationToken = default); + /// - /// Send a password reset email + /// Send a password reset email. Not retry-queued: a failed reset can simply be re-requested. /// /// /// /// - /// public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default); - + /// - /// When a user uses changes their email, we send them this email to let them verify it + /// When a user changes their email, we send them this email to let them verify it. /// + /// The id of the account whose email is being changed. /// /// /// - /// - public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default); + public Task VerifyEmail(Guid userId, Contact to, Uri verificationLink, CancellationToken cancellationToken = default); /// /// Informational notice sent to the user's previous email address when an email change is /// initiated. Contains no action link — its only purpose is to alert the legitimate owner /// of the address that a change request was started, in case the account was compromised. /// + /// The id of the account whose email is being changed. /// The old email address being notified. /// The new email address that was requested. /// - public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default); + public Task EmailChangeNotice(Guid userId, Contact to, string newEmail, CancellationToken cancellationToken = default); } diff --git a/API/Services/Email/Queue/QueueingEmailService.cs b/API/Services/Email/Queue/QueueingEmailService.cs new file mode 100644 index 00000000..9d960b05 --- /dev/null +++ b/API/Services/Email/Queue/QueueingEmailService.cs @@ -0,0 +1,133 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email; +using OpenShock.Common.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Queue; + +namespace OpenShock.API.Services.Email.Queue; + +/// +/// decorator implementing the send-now / queue-on-failure behaviour. +/// It hands the email straight to the underlying ; if delivery fails +/// transiently it records a minimal, token-free for the background worker to +/// retry. Permanent failures are logged and dropped. The caller's action never fails because of mail. +/// +public sealed class QueueingEmailService : IEmailService +{ + private const int MaxErrorLength = 1000; + + private readonly IEmailSender _sender; + private readonly IDbContextFactory _dbContextFactory; + private readonly EmailQueueOptions _options; + private readonly ILogger _logger; + + public QueueingEmailService( + IEmailSender sender, + IDbContextFactory dbContextFactory, + EmailQueueOptions options, + ILogger logger) + { + _sender = sender; + _dbContextFactory = dbContextFactory; + _options = options; + _logger = logger; + } + + /// + public Task ActivateAccount(Guid userId, Contact to, Uri activationLink, CancellationToken cancellationToken = default) + => SendOrQueue( + QueuedEmailType.AccountActivation, + new QueuedEmailPayloads.Activation(userId, to.Email), + () => _sender.ActivateAccount(to, activationLink, cancellationToken), + cancellationToken); + + /// + public async Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) + { + // Password reset is intentionally not queued: a failed reset can simply be re-requested by the + // user, and we don't want a background job minting fresh reset tokens. Swallow + log instead. + try + { + await _sender.PasswordReset(to, resetLink, cancellationToken); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to send password reset email; not retrying"); + } + } + + /// + public Task VerifyEmail(Guid userId, Contact to, Uri verificationLink, CancellationToken cancellationToken = default) + => SendOrQueue( + QueuedEmailType.EmailVerification, + new QueuedEmailPayloads.EmailVerification(userId, to.Email), + () => _sender.VerifyEmail(to, verificationLink, cancellationToken), + cancellationToken); + + /// + public Task EmailChangeNotice(Guid userId, Contact to, string newEmail, CancellationToken cancellationToken = default) + => SendOrQueue( + QueuedEmailType.EmailChangeNotice, + new QueuedEmailPayloads.EmailChangeNotice(userId, to.Email, newEmail), + () => _sender.EmailChangeNotice(to, newEmail, cancellationToken), + cancellationToken); + + private async Task SendOrQueue(QueuedEmailType type, object payload, Func send, CancellationToken cancellationToken) + { + try + { + await send(); + } + catch (OperationCanceledException) + { + throw; + } + catch (EmailDeliveryException ex) when (!ex.IsTransient) + { + // Permanent provider rejection — retrying would just fail again. + _logger.LogError(ex, "Permanent failure sending {EmailType} email; dropping", type); + } + catch (Exception ex) + { + // Transient EmailDeliveryException or any unexpected error — queue for retry. + _logger.LogWarning(ex, "Failed to send {EmailType} email; queueing for retry", type); + await EnqueueAsync(type, payload, ex); + } + } + + private async Task EnqueueAsync(QueuedEmailType type, object payload, Exception ex) + { + try + { + // Use None so the row is persisted even if the originating request is being torn down. + await using var db = await _dbContextFactory.CreateDbContextAsync(CancellationToken.None); + + db.QueuedEmails.Add(new QueuedEmail + { + Id = Guid.CreateVersion7(), + Type = type, + Payload = JsonSerializer.SerializeToDocument(payload), + Attempts = 1, // the just-failed send counts as the first attempt + NextAttemptAt = DateTime.UtcNow + _options.GetRetryDelay(1), + LastError = Truncate(ex.Message) + }); + + await db.SaveChangesAsync(CancellationToken.None); + } + catch (Exception enqueueEx) + { + // Last resort: the email is lost, but a queue write failure must not crash the caller. + _logger.LogError(enqueueEx, "Failed to queue {EmailType} email for retry", type); + } + } + + private static string Truncate(string value) + => value.Length <= MaxErrorLength ? value : value[..MaxErrorLength]; +} diff --git a/API/Services/Email/Smtp/SmtpEmailService.cs b/API/Services/Email/Smtp/SmtpEmailService.cs deleted file mode 100644 index bcce832b..00000000 --- a/API/Services/Email/Smtp/SmtpEmailService.cs +++ /dev/null @@ -1,77 +0,0 @@ -using MailKit.Net.Smtp; -using MimeKit; -using MimeKit.Text; -using OpenShock.API.Options; -using OpenShock.API.Services.Email.Mailjet.Mail; - -namespace OpenShock.API.Services.Email.Smtp; - -public sealed class SmtpEmailService : IEmailService -{ - private readonly EmailServiceTemplates _templates; - private readonly SmtpOptions _options; - private readonly MailboxAddress _sender; - private readonly ILogger _logger; - - public SmtpEmailService( - EmailServiceTemplates templates, - SmtpOptions options, - MailOptions.MailSenderContact sender, - ILogger logger - ) - { - _templates = templates; - _options = options; - _sender = sender.ToMailAddress(); - _logger = logger; - } - - public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) - => SendMail(to, _templates.AccountActivation, new { To = to, ActivationLink = activationLink }, cancellationToken); - - /// - public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) - => SendMail(to, _templates.PasswordReset, new { To = to, ResetLink = resetLink }, cancellationToken); - - /// - public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) - => SendMail(to, _templates.EmailVerification, new { To = to, VerifyLink = verificationLink }, cancellationToken); - - /// - public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) - => SendMail(to, _templates.EmailChangeNotice, new { To = to, NewEmail = newEmail }, cancellationToken); - - private async Task SendMail(Contact to, EmailTemplate template, T data, CancellationToken cancellationToken = default) - { - _logger.LogDebug("Sending email"); - var (subject, htmlBody) = await template.RenderAsync(data); - - var message = new MimeMessage - { - From = { _sender }, - Sender = _sender, - To = { to.ToMailAddress() }, - Subject = subject, - Body = new TextPart(TextFormat.Html) { Text = htmlBody } - }; - - _logger.LogTrace("Creating smtp client and connecting..."); - using var smtpClient = new SmtpClient(); - if (!_options.VerifyCertificate) - { - smtpClient.ServerCertificateValidationCallback = (sender, certificate, chain, errors) => true; - smtpClient.CheckCertificateRevocation = false; - } - - await smtpClient.ConnectAsync(_options.Host, _options.Port, _options.EnableSsl, cancellationToken); - _logger.LogTrace("Authenticating..."); - if (smtpClient.Capabilities.HasFlag(SmtpCapabilities.Authentication)) - await smtpClient.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); - - _logger.LogTrace("Smtp client connected, sending email..."); - - await smtpClient.SendAsync(message, cancellationToken); - await smtpClient.DisconnectAsync(true, cancellationToken); - _logger.LogTrace("Sent email"); - } -} \ No newline at end of file diff --git a/Common/Common.csproj b/Common/Common.csproj index a1eb241f..36c5f620 100644 --- a/Common/Common.csproj +++ b/Common/Common.csproj @@ -7,6 +7,8 @@ + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -35,6 +37,16 @@ + + + + PreserveNewest + PreserveNewest + + + diff --git a/Common/Migrations/20260624114124_AddEmailQueue.Designer.cs b/Common/Migrations/20260624114124_AddEmailQueue.Designer.cs new file mode 100644 index 00000000..ce8ac5db --- /dev/null +++ b/Common/Migrations/20260624114124_AddEmailQueue.Designer.cs @@ -0,0 +1,1561 @@ +// +using System; +using System.Collections.Generic; +using System.Net; +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + [DbContext(typeof(MigrationOpenShockContext))] + [Migration("20260624114124_AddEmailQueue")] + partial class AddEmailQueue + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "match_type_enum", new[] { "exact", "contains" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "queued_email_type", new[] { "account_activation", "email_verification", "email_change_notice" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.AdminUsersView", b => + { + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("ApiTokenCount") + .HasColumnType("integer") + .HasColumnName("api_token_count"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at"); + + b.Property("DeactivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("deactivated_at"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeviceCount") + .HasColumnType("integer") + .HasColumnName("device_count"); + + b.Property("Email") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("email"); + + b.Property("EmailChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("email_change_request_count"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Name") + .IsRequired() + .HasColumnType("character varying") + .HasColumnName("name"); + + b.Property("NameChangeRequestCount") + .HasColumnType("integer") + .HasColumnName("name_change_request_count"); + + b.Property("PasswordHashType") + .HasColumnType("character varying") + .HasColumnName("password_hash_type"); + + b.Property("PasswordResetCount") + .HasColumnType("integer") + .HasColumnName("password_reset_count"); + + b.Property("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("ShockerControlLogCount") + .HasColumnType("integer") + .HasColumnName("shocker_control_log_count"); + + b.Property("ShockerCount") + .HasColumnType("integer") + .HasColumnName("shocker_count"); + + b.Property("ShockerPublicShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_public_share_count"); + + b.Property("ShockerUserShareCount") + .HasColumnType("integer") + .HasColumnName("shocker_user_share_count"); + + b.ToTable((string)null); + + b.ToView("admin_users_view", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CreatedByIp") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("created_by_ip"); + + b.Property("LastUsed") + .HasColumnType("timestamp with time zone") + .HasColumnName("last_used"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.PrimitiveCollection>("Permissions") + .IsRequired() + .HasColumnType("permission_type[]") + .HasColumnName("permissions"); + + b.Property("ShockerControlDurationMax") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(65535) + .HasColumnName("shocker_control_duration_max"); + + b.Property("ShockerControlDurationMin") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(300) + .HasColumnName("shocker_control_duration_min"); + + b.Property("ShockerControlDurationMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_duration_mode"); + + b.Property("ShockerControlIntensityMax") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)100) + .HasColumnName("shocker_control_intensity_max"); + + b.Property("ShockerControlIntensityMin") + .ValueGeneratedOnAdd() + .HasColumnType("smallint") + .HasDefaultValue((byte)0) + .HasColumnName("shocker_control_intensity_min"); + + b.Property("ShockerControlIntensityMode") + .ValueGeneratedOnAdd() + .HasColumnType("control_limit_mode") + .HasDefaultValue(ControlLimitMode.Clamp) + .HasColumnName("shocker_control_intensity_mode"); + + b.Property("ShockerControlPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("shocker_control_paused"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("ValidUntil") + .HasColumnType("timestamp with time zone") + .HasColumnName("valid_until"); + + b.HasKey("Id") + .HasName("api_tokens_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.HasIndex("ValidUntil"); + + b.ToTable("api_tokens", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AffectedCount") + .HasColumnType("integer") + .HasColumnName("affected_count"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IpAddress") + .IsRequired() + .HasColumnType("inet") + .HasColumnName("ip_address"); + + b.Property("IpCountry") + .HasColumnType("text") + .HasColumnName("ip_country"); + + b.Property("SubmittedCount") + .HasColumnType("integer") + .HasColumnName("submitted_count"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("api_token_reports_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("api_token_reports", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ConfigurationItem", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name") + .UseCollation("C"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text") + .HasColumnName("description"); + + b.Property("Type") + .HasColumnType("configuration_value_type") + .HasColumnName("type"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("updated_at"); + + b.Property("Value") + .IsRequired() + .HasColumnType("text") + .HasColumnName("value"); + + b.HasKey("Name") + .HasName("configuration_pkey"); + + b.ToTable("configuration", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)") + .HasColumnName("token") + .UseCollation("C"); + + b.HasKey("Id") + .HasName("devices_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("devices", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("UpdateId") + .HasColumnType("integer") + .HasColumnName("update_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Message") + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("message"); + + b.Property("Status") + .HasColumnType("ota_update_status") + .HasColumnName("status"); + + b.Property("Version") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("version"); + + b.HasKey("DeviceId", "UpdateId") + .HasName("device_ota_updates_pkey"); + + b.HasIndex(new[] { "CreatedAt" }, "device_ota_updates_created_at_idx"); + + b.ToTable("device_ota_updates", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DiscordWebhook", b => + { + b.Property("Name") + .HasColumnType("text") + .HasColumnName("name"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("WebhookId") + .HasColumnType("bigint") + .HasColumnName("webhook_id"); + + b.Property("WebhookToken") + .IsRequired() + .HasColumnType("text") + .HasColumnName("webhook_token"); + + b.HasKey("Name") + .HasName("discord_webhooks_pkey"); + + b.ToTable("discord_webhooks", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailProviderBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Domain") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)") + .HasColumnName("domain") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("email_provider_blacklist_pkey"); + + b.HasIndex("Domain") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Domain"), new[] { "ndcoll" }); + + b.ToTable("email_provider_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("expires_at"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.HasKey("Id") + .HasName("public_shares_pkey"); + + b.HasIndex("OwnerId"); + + b.ToTable("public_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.Property("PublicShareId") + .HasColumnType("uuid") + .HasColumnName("public_share_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("Cooldown") + .HasColumnType("integer") + .HasColumnName("cooldown"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("PublicShareId", "ShockerId") + .HasName("public_share_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("public_share_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.QueuedEmail", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Type") + .HasColumnType("queued_email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("queued_emails_pkey"); + + b.HasIndex("NextAttemptAt"); + + b.ToTable("queued_emails", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeviceId") + .HasColumnType("uuid") + .HasColumnName("device_id"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("Model") + .HasColumnType("shocker_model_type") + .HasColumnName("model"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("name"); + + b.Property("RfId") + .HasColumnType("integer") + .HasColumnName("rf_id"); + + b.HasKey("Id") + .HasName("shockers_pkey"); + + b.HasIndex("DeviceId"); + + b.ToTable("shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ControlledByUserId") + .HasColumnType("uuid") + .HasColumnName("controlled_by_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("CustomName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("custom_name"); + + b.Property("Duration") + .HasColumnType("bigint") + .HasColumnName("duration"); + + b.Property("Intensity") + .HasColumnType("smallint") + .HasColumnName("intensity"); + + b.Property("LiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("live_control"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("Type") + .HasColumnType("control_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("shocker_control_logs_pkey"); + + b.HasIndex("ControlledByUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_control_logs", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.HasKey("Id") + .HasName("shocker_share_codes_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("shocker_share_codes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("activated_at"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("name") + .UseCollation("ndcoll"); + + b.Property("PasswordHash") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("password_hash") + .UseCollation("C"); + + b.PrimitiveCollection>("Roles") + .IsRequired() + .HasColumnType("role_type[]") + .HasColumnName("roles"); + + b.Property("SecurityStamp") + .ValueGeneratedOnAdd() + .HasColumnType("uuid") + .HasColumnName("security_stamp") + .HasDefaultValueSql("gen_random_uuid()"); + + b.HasKey("Id") + .HasName("users_pkey"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("Name") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Name"), new[] { "ndcoll" }); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("EmailSendAttempts") + .HasColumnType("integer") + .HasColumnName("email_send_attempts"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.HasKey("UserId") + .HasName("user_activation_requests_pkey"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.ToTable("user_activation_requests", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.Property("DeactivatedUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DeactivatedByUserId") + .HasColumnType("uuid") + .HasColumnName("deactivated_by_user_id"); + + b.Property("DeleteLater") + .HasColumnType("boolean") + .HasColumnName("delete_later"); + + b.Property("UserModerationId") + .HasColumnType("uuid") + .HasColumnName("user_moderation_id"); + + b.HasKey("DeactivatedUserId") + .HasName("user_deactivations_pkey"); + + b.HasIndex("DeactivatedByUserId"); + + b.ToTable("user_deactivations", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("NewEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_new"); + + b.Property("OldEmail") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)") + .HasColumnName("email_old"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_email_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("UsedAt"); + + b.HasIndex("UserId"); + + b.ToTable("user_email_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameBlacklist", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("MatchType") + .HasColumnType("match_type_enum") + .HasColumnName("match_type"); + + b.Property("Value") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("value") + .UseCollation("ndcoll"); + + b.HasKey("Id") + .HasName("user_name_blacklist_pkey"); + + b.HasIndex("Value") + .IsUnique(); + + NpgsqlIndexBuilderExtensions.UseCollation(b.HasIndex("Value"), new[] { "ndcoll" }); + + b.ToTable("user_name_blacklist", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasColumnName("id"); + + NpgsqlPropertyBuilderExtensions.UseIdentityAlwaysColumn(b.Property("Id")); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OldName") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)") + .HasColumnName("old_name"); + + b.HasKey("Id", "UserId") + .HasName("user_name_changes_pkey"); + + b.HasIndex("CreatedAt"); + + b.HasIndex("OldName"); + + b.HasIndex("UserId"); + + b.ToTable("user_name_changes", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.Property("ProviderKey") + .HasColumnType("text") + .HasColumnName("provider_key") + .UseCollation("C"); + + b.Property("ExternalId") + .HasColumnType("text") + .HasColumnName("external_id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("DisplayName") + .HasColumnType("text") + .HasColumnName("display_name"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("ProviderKey", "ExternalId") + .HasName("user_oauth_connections_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_oauth_connections", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("SecurityStampAtCreate") + .HasColumnType("uuid") + .HasColumnName("security_stamp_at_create"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("token_hash") + .UseCollation("C"); + + b.Property("UsedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("used_at"); + + b.Property("UserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_password_resets_pkey"); + + b.HasIndex("UserId"); + + b.ToTable("user_password_resets", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.Property("SharedWithUserId") + .HasColumnType("uuid") + .HasColumnName("shared_with_user_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("SharedWithUserId", "ShockerId") + .HasName("user_shares_pkey"); + + b.HasIndex("SharedWithUserId"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_shares", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("OwnerId") + .HasColumnType("uuid") + .HasColumnName("owner_id"); + + b.Property("RecipientUserId") + .HasColumnType("uuid") + .HasColumnName("user_id"); + + b.HasKey("Id") + .HasName("user_share_invites_pkey"); + + b.HasIndex("OwnerId"); + + b.HasIndex("RecipientUserId"); + + b.ToTable("user_share_invites", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.Property("InviteId") + .HasColumnType("uuid") + .HasColumnName("invite_id"); + + b.Property("ShockerId") + .HasColumnType("uuid") + .HasColumnName("shocker_id"); + + b.Property("AllowLiveControl") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_livecontrol"); + + b.Property("AllowShock") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_shock"); + + b.Property("AllowSound") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_sound"); + + b.Property("AllowVibrate") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true) + .HasColumnName("allow_vibrate"); + + b.Property("IsPaused") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("is_paused"); + + b.Property("MaxDuration") + .HasColumnType("integer") + .HasColumnName("max_duration"); + + b.Property("MaxIntensity") + .HasColumnType("smallint") + .HasColumnName("max_intensity"); + + b.HasKey("InviteId", "ShockerId") + .HasName("user_share_invite_shockers_pkey"); + + b.HasIndex("ShockerId"); + + b.ToTable("user_share_invite_shockers", (string)null); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiToken", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("ApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_tokens_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ApiTokenReport", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ReportedByUser") + .WithMany("ReportedApiTokens") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_api_token_reports_reported_by_user_id"); + + b.Navigation("ReportedByUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("Devices") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_devices_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.DeviceOtaUpdate", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("OtaUpdates") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_device_ota_updates_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OwnedPublicShares") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_shares_owner_id"); + + b.Navigation("Owner"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShareShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.PublicShare", "PublicShare") + .WithMany("ShockerMappings") + .HasForeignKey("PublicShareId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_public_share_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("PublicShareMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_public_share_shockers_shocker_id"); + + b.Navigation("PublicShare"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Device", "Device") + .WithMany("Shockers") + .HasForeignKey("DeviceId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shockers_device_id"); + + b.Navigation("Device"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerControlLog", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "ControlledByUser") + .WithMany("ShockerControlLogs") + .HasForeignKey("ControlledByUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_shocker_control_logs_controlled_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerControlLogs") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_control_logs_shocker_id"); + + b.Navigation("ControlledByUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.ShockerShareCode", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("ShockerShareCodes") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_shocker_share_codes_shocker_id"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserActivationRequest", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithOne("UserActivationRequest") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserActivationRequest", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_activation_requests_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserDeactivation", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedByUser") + .WithMany() + .HasForeignKey("DeactivatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_by_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "DeactivatedUser") + .WithOne("UserDeactivation") + .HasForeignKey("OpenShock.Common.OpenShockDb.UserDeactivation", "DeactivatedUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_deactivations_deactivated_user_id"); + + b.Navigation("DeactivatedByUser"); + + b.Navigation("DeactivatedUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserEmailChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("EmailChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_email_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserNameChange", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("NameChanges") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_name_changes_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserOAuthConnection", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("OAuthConnections") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_oauth_connections_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserPasswordReset", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "User") + .WithMany("PasswordResets") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_password_resets_user_id"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShare", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "SharedWithUser") + .WithMany("IncomingUserShares") + .HasForeignKey("SharedWithUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shared_with_user_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShares") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_shares_shocker_id"); + + b.Navigation("SharedWithUser"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.User", "Owner") + .WithMany("OutgoingUserShareInvites") + .HasForeignKey("OwnerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invites_owner_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.User", "RecipientUser") + .WithMany("IncomingUserShareInvites") + .HasForeignKey("RecipientUserId") + .OnDelete(DeleteBehavior.Cascade) + .HasConstraintName("fk_user_share_invites_recipient_user_id"); + + b.Navigation("Owner"); + + b.Navigation("RecipientUser"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInviteShocker", b => + { + b.HasOne("OpenShock.Common.OpenShockDb.UserShareInvite", "Invite") + .WithMany("ShockerMappings") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_invite_id"); + + b.HasOne("OpenShock.Common.OpenShockDb.Shocker", "Shocker") + .WithMany("UserShareInviteShockerMappings") + .HasForeignKey("ShockerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_user_share_invite_shockers_shocker_id"); + + b.Navigation("Invite"); + + b.Navigation("Shocker"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Device", b => + { + b.Navigation("OtaUpdates"); + + b.Navigation("Shockers"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.PublicShare", b => + { + b.Navigation("ShockerMappings"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => + { + b.Navigation("PublicShareMappings"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("ShockerShareCodes"); + + b.Navigation("UserShareInviteShockerMappings"); + + b.Navigation("UserShares"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.User", b => + { + b.Navigation("ApiTokens"); + + b.Navigation("Devices"); + + b.Navigation("EmailChanges"); + + b.Navigation("IncomingUserShareInvites"); + + b.Navigation("IncomingUserShares"); + + b.Navigation("NameChanges"); + + b.Navigation("OAuthConnections"); + + b.Navigation("OutgoingUserShareInvites"); + + b.Navigation("OwnedPublicShares"); + + b.Navigation("PasswordResets"); + + b.Navigation("ReportedApiTokens"); + + b.Navigation("ShockerControlLogs"); + + b.Navigation("UserActivationRequest"); + + b.Navigation("UserDeactivation"); + }); + + modelBuilder.Entity("OpenShock.Common.OpenShockDb.UserShareInvite", b => + { + b.Navigation("ShockerMappings"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Common/Migrations/20260624114124_AddEmailQueue.cs b/Common/Migrations/20260624114124_AddEmailQueue.cs new file mode 100644 index 00000000..18c50e3b --- /dev/null +++ b/Common/Migrations/20260624114124_AddEmailQueue.cs @@ -0,0 +1,92 @@ +using System; +using System.Text.Json; +using Microsoft.EntityFrameworkCore.Migrations; +using OpenShock.Common.Models; + +#nullable disable + +namespace OpenShock.Common.Migrations +{ + /// + public partial class AddEmailQueue : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:queued_email_type", "account_activation,email_verification,email_change_notice") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + + migrationBuilder.CreateTable( + name: "queued_emails", + columns: table => new + { + id = table.Column(type: "uuid", nullable: false), + type = table.Column(type: "queued_email_type", nullable: false), + payload = table.Column(type: "jsonb", nullable: false), + attempts = table.Column(type: "integer", nullable: false), + next_attempt_at = table.Column(type: "timestamp with time zone", nullable: false), + last_error = table.Column(type: "text", nullable: true), + created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP") + }, + constraints: table => + { + table.PrimaryKey("queued_emails_pkey", x => x.id); + }); + + migrationBuilder.CreateIndex( + name: "IX_queued_emails_next_attempt_at", + table: "queued_emails", + column: "next_attempt_at"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "queued_emails"); + + migrationBuilder.AlterDatabase() + .Annotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .Annotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .Annotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .Annotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .Annotation("Npgsql:Enum:match_type_enum", "exact,contains") + .Annotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .Annotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .Annotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .Annotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .Annotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330") + .OldAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") + .OldAnnotation("Npgsql:Enum:configuration_value_type", "string,bool,int,float,json") + .OldAnnotation("Npgsql:Enum:control_limit_mode", "clamp,lerp") + .OldAnnotation("Npgsql:Enum:control_type", "sound,vibrate,shock,stop") + .OldAnnotation("Npgsql:Enum:match_type_enum", "exact,contains") + .OldAnnotation("Npgsql:Enum:ota_update_status", "started,running,finished,error,timeout") + .OldAnnotation("Npgsql:Enum:password_encryption_type", "pbkdf2,bcrypt_enhanced") + .OldAnnotation("Npgsql:Enum:permission_type", "shockers.use,shockers.edit,shockers.pause,devices.edit,devices.auth") + .OldAnnotation("Npgsql:Enum:queued_email_type", "account_activation,email_verification,email_change_notice") + .OldAnnotation("Npgsql:Enum:role_type", "support,staff,admin,system") + .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330"); + } + } +} diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs index 81f0c618..a53112a1 100644 --- a/Common/Migrations/OpenShockContextModelSnapshot.cs +++ b/Common/Migrations/OpenShockContextModelSnapshot.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; using System.Net; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; @@ -21,7 +22,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) #pragma warning disable 612, 618 modelBuilder .HasAnnotation("Npgsql:CollationDefinition:public.ndcoll", "und-u-ks-level2,und-u-ks-level2,icu,False") - .HasAnnotation("ProductVersion", "10.0.8") + .HasAnnotation("ProductVersion", "10.0.9") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "configuration_value_type", new[] { "string", "bool", "int", "float", "json" }); @@ -31,6 +32,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "ota_update_status", new[] { "started", "running", "finished", "error", "timeout" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "password_encryption_type", new[] { "pbkdf2", "bcrypt_enhanced" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "permission_type", new[] { "shockers.use", "shockers.edit", "shockers.pause", "devices.edit", "devices.auth" }); + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "queued_email_type", new[] { "account_activation", "email_verification", "email_change_notice" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "role_type", new[] { "support", "staff", "admin", "system" }); NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "shocker_model_type", new[] { "caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330" }); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -550,6 +552,47 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("public_share_shockers", (string)null); }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.QueuedEmail", b => + { + b.Property("Id") + .HasColumnType("uuid") + .HasColumnName("id"); + + b.Property("Attempts") + .HasColumnType("integer") + .HasColumnName("attempts"); + + b.Property("CreatedAt") + .ValueGeneratedOnAdd() + .HasColumnType("timestamp with time zone") + .HasColumnName("created_at") + .HasDefaultValueSql("CURRENT_TIMESTAMP"); + + b.Property("LastError") + .HasColumnType("text") + .HasColumnName("last_error"); + + b.Property("NextAttemptAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("next_attempt_at"); + + b.Property("Payload") + .IsRequired() + .HasColumnType("jsonb") + .HasColumnName("payload"); + + b.Property("Type") + .HasColumnType("queued_email_type") + .HasColumnName("type"); + + b.HasKey("Id") + .HasName("queued_emails_pkey"); + + b.HasIndex("NextAttemptAt"); + + b.ToTable("queued_emails", (string)null); + }); + modelBuilder.Entity("OpenShock.Common.OpenShockDb.Shocker", b => { b.Property("Id") diff --git a/Common/Models/QueuedEmailType.cs b/Common/Models/QueuedEmailType.cs new file mode 100644 index 00000000..e0a33560 --- /dev/null +++ b/Common/Models/QueuedEmailType.cs @@ -0,0 +1,14 @@ +using NpgsqlTypes; + +namespace OpenShock.Common.Models; + +/// +/// Discriminates the kind of email a represents. +/// The row's JSONB payload shape depends on this value. +/// +public enum QueuedEmailType +{ + [PgName("account_activation")] AccountActivation = 0, + [PgName("email_verification")] EmailVerification = 1, + [PgName("email_change_notice")] EmailChangeNotice = 2, +} diff --git a/Common/OpenShockDb/OpenShockContext.cs b/Common/OpenShockDb/OpenShockContext.cs index 66de70f0..a8321c3a 100644 --- a/Common/OpenShockDb/OpenShockContext.cs +++ b/Common/OpenShockDb/OpenShockContext.cs @@ -71,6 +71,7 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); npgsqlBuilder.MapEnum(); + npgsqlBuilder.MapEnum(); }); if (debug) @@ -127,7 +128,9 @@ public static void ConfigureOptionsBuilder(DbContextOptionsBuilder optionsBuilde public DbSet UserNameBlacklists { get; set; } public DbSet EmailProviderBlacklists { get; set; } - + + public DbSet QueuedEmails { get; set; } + public DbSet DataProtectionKeys { get; set; } protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) @@ -151,6 +154,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasPostgresEnum("shocker_model_type", ["caiXianlin", "petTrainer", "petrainer998DR", "wellturnT330"]) .HasPostgresEnum("match_type_enum", ["exact", "contains"]) .HasPostgresEnum("configuration_value_type", ["string", "bool", "int", "float", "json"]) + .HasPostgresEnum("queued_email_type", ["account_activation", "email_verification", "email_change_notice"]) .HasCollation("public", "ndcoll", "und-u-ks-level2", "icu", false); // Add case-insensitive, accent-sensitive comparison collation modelBuilder.Entity(entity => @@ -872,6 +876,34 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) .HasColumnName("created_at"); }); + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id).HasName("queued_emails_pkey"); + + entity.ToTable("queued_emails"); + + entity.HasIndex(e => e.NextAttemptAt); + + entity.Property(e => e.Id) + .ValueGeneratedNever() + .HasColumnName("id"); + entity.Property(e => e.Type) + .HasColumnType("queued_email_type") + .HasColumnName("type"); + entity.Property(e => e.Payload) + .HasColumnType("jsonb") + .HasColumnName("payload"); + entity.Property(e => e.Attempts) + .HasColumnName("attempts"); + entity.Property(e => e.NextAttemptAt) + .HasColumnName("next_attempt_at"); + entity.Property(e => e.LastError) + .HasColumnName("last_error"); + entity.Property(e => e.CreatedAt) + .HasDefaultValueSql("CURRENT_TIMESTAMP") + .HasColumnName("created_at"); + }); + modelBuilder.Entity(entity => { entity diff --git a/Common/OpenShockDb/QueuedEmail.cs b/Common/OpenShockDb/QueuedEmail.cs new file mode 100644 index 00000000..da54312b --- /dev/null +++ b/Common/OpenShockDb/QueuedEmail.cs @@ -0,0 +1,45 @@ +using System.Text.Json; +using OpenShock.Common.Models; + +namespace OpenShock.Common.OpenShockDb; + +/// +/// An email that failed to send to its upstream provider and is queued for a later retry. +/// +/// Only the minimal, non-secret information needed to reproduce the send is stored: the +/// discriminator and a JSONB whose shape depends on it. +/// Tokens / activation links are never persisted here, the retry worker regenerates a fresh token +/// right before sending. +/// +public sealed class QueuedEmail +{ + public required Guid Id { get; set; } + + /// + /// The kind of email this row represents. Determines how is interpreted. + /// + public required QueuedEmailType Type { get; set; } + + /// + /// JSONB payload holding the type-specific, non-secret data needed to regenerate and resend the + /// email (e.g. the target user id and recipient address). Never contains tokens. + /// + public required JsonDocument Payload { get; set; } + + /// + /// Number of send attempts made so far (the original failed send counts as the first attempt). + /// + public int Attempts { get; set; } + + /// + /// The earliest time the retry worker may attempt this email again. + /// + public DateTime NextAttemptAt { get; set; } + + /// + /// The error message from the most recent failed attempt, for diagnostics. + /// + public string? LastError { get; set; } + + public DateTime CreatedAt { get; set; } +} diff --git a/Common/Options/EmailQueueOptions.cs b/Common/Options/EmailQueueOptions.cs new file mode 100644 index 00000000..5ee4dc64 --- /dev/null +++ b/Common/Options/EmailQueueOptions.cs @@ -0,0 +1,40 @@ +namespace OpenShock.Common.Options; + +/// +/// Tuning for the email retry queue worker. Bound from OpenShock:Mail:Queue; every value has a +/// sensible default so the section can be omitted entirely. +/// +public sealed class EmailQueueOptions +{ + public const string SectionName = "OpenShock:Mail:Queue"; + + /// How often the worker scans for due emails. + public int PollIntervalSeconds { get; init; } = 30; + + /// Maximum number of due emails processed per scan. + public int BatchSize { get; init; } = 50; + + /// Number of attempts before a queued email is given up on and dropped. + public int MaxAttempts { get; init; } = 10; + + /// Base delay for the exponential backoff between attempts. + public int BaseRetryDelaySeconds { get; init; } = 60; + + /// Upper bound on the backoff delay between attempts. + public int MaxRetryDelaySeconds { get; init; } = 3600; + + /// + /// Exponential backoff for the next attempt, capped at . + /// is the number of attempts already made (1 after the first failure). + /// + public TimeSpan GetRetryDelay(int attempts) + { + var exponent = Math.Max(0, attempts - 1); + // Cap the exponent before shifting to avoid overflow on pathological attempt counts. + var seconds = exponent >= 20 + ? MaxRetryDelaySeconds + : Math.Min((double)MaxRetryDelaySeconds, BaseRetryDelaySeconds * Math.Pow(2, exponent)); + + return TimeSpan.FromSeconds(seconds); + } +} diff --git a/API/Options/MailJetOptions.cs b/Common/Options/MailJetOptions.cs similarity index 93% rename from API/Options/MailJetOptions.cs rename to Common/Options/MailJetOptions.cs index 44691434..7e5d39ee 100644 --- a/API/Options/MailJetOptions.cs +++ b/Common/Options/MailJetOptions.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; using System.ComponentModel.DataAnnotations; -namespace OpenShock.API.Options; +namespace OpenShock.Common.Options; public sealed class MailJetOptions { diff --git a/API/Options/MailOptions.cs b/Common/Options/MailOptions.cs similarity index 82% rename from API/Options/MailOptions.cs rename to Common/Options/MailOptions.cs index b4fd21a5..f6d67eee 100644 --- a/API/Options/MailOptions.cs +++ b/Common/Options/MailOptions.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Mailjet.Mail; -namespace OpenShock.API.Options; +namespace OpenShock.Common.Options; public sealed class MailOptions { diff --git a/API/Options/SmtpOptions.cs b/Common/Options/SmtpOptions.cs similarity index 94% rename from API/Options/SmtpOptions.cs rename to Common/Options/SmtpOptions.cs index 73ae1f6f..b5fa90b8 100644 --- a/API/Options/SmtpOptions.cs +++ b/Common/Options/SmtpOptions.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; using System.ComponentModel.DataAnnotations; -namespace OpenShock.API.Options; +namespace OpenShock.Common.Options; public sealed class SmtpOptions { diff --git a/Common/Services/Email/EmailDeliveryException.cs b/Common/Services/Email/EmailDeliveryException.cs new file mode 100644 index 00000000..2396479b --- /dev/null +++ b/Common/Services/Email/EmailDeliveryException.cs @@ -0,0 +1,20 @@ +namespace OpenShock.Common.Services.Email; + +/// +/// Thrown by an when handing an email to the upstream provider fails. +/// distinguishes failures that are worth retrying (provider 5xx / 429, +/// network blips) from permanent ones (4xx such as a malformed request or rejected recipient). +/// +public sealed class EmailDeliveryException : Exception +{ + /// + /// True if the failure is expected to be temporary and the send should be retried later. + /// + public bool IsTransient { get; } + + public EmailDeliveryException(bool isTransient, string message, Exception? innerException = null) + : base(message, innerException) + { + IsTransient = isTransient; + } +} diff --git a/Common/Services/Email/EmailSenderServiceExtension.cs b/Common/Services/Email/EmailSenderServiceExtension.cs new file mode 100644 index 00000000..78d5d5a3 --- /dev/null +++ b/Common/Services/Email/EmailSenderServiceExtension.cs @@ -0,0 +1,79 @@ +using Microsoft.AspNetCore.Builder; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email.Mailjet; +using OpenShock.Common.Services.Email.Smtp; + +namespace OpenShock.Common.Services.Email; + +public static class EmailSenderServiceExtension +{ + /// + /// Registers the raw (Mailjet / SMTP / none) plus the shared email + /// configuration (sender contact, rendered templates, retry-queue tuning). Used by every host that + /// sends mail — the API send path and the Cron retry worker. + /// + public static async Task AddOpenShockEmailSender(this WebApplicationBuilder builder) + { + var mailOptions = builder.Configuration.GetRequiredSection(MailOptions.SectionName).Get() ?? throw new NullReferenceException(); + + // Always available so the queueing decorator and the retry processor can read it. + builder.AddEmailQueueOptions(); + + if (mailOptions.Type == MailOptions.MailType.None) + { + builder.Services.AddSingleton(); // Add a dummy email sender + return builder; + } + + builder.AddSenderContactConfiguration(); + await builder.AddEmailServiceTemplates(); + + switch (mailOptions.Type) + { + case MailOptions.MailType.Mailjet: + builder.AddMailjetEmailService(); + break; + case MailOptions.MailType.Smtp: + builder.AddSmtpEmailService(); + break; + default: + throw new Exception("Unknown mail type"); + } + + return builder; + } + + private static WebApplicationBuilder AddSenderContactConfiguration(this WebApplicationBuilder builder) + { + builder.Services.AddSingleton(builder.Configuration.GetRequiredSection(MailOptions.SenderSectionName).Get() ?? throw new NullReferenceException()); + return builder; + } + + private static WebApplicationBuilder AddEmailQueueOptions(this WebApplicationBuilder builder) + { + var options = builder.Configuration.GetSection(EmailQueueOptions.SectionName).Get() ?? new EmailQueueOptions(); + builder.Services.AddSingleton(options); + return builder; + } + + private static async Task AddEmailServiceTemplates(this WebApplicationBuilder builder) + { + var accountActivation = EmailTemplate.ParseFromFileThrow("SmtpTemplates/AccountActivation.liquid"); + var passwordReset = EmailTemplate.ParseFromFileThrow("SmtpTemplates/PasswordReset.liquid"); + var emailVerification = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailVerification.liquid"); + var emailChangeNotice = EmailTemplate.ParseFromFileThrow("SmtpTemplates/EmailChangeNotice.liquid"); + + await Task.WhenAll(accountActivation, passwordReset, emailVerification, emailChangeNotice); + + builder.Services.AddSingleton(new EmailServiceTemplates + { + AccountActivation = await accountActivation, + PasswordReset = await passwordReset, + EmailVerification = await emailVerification, + EmailChangeNotice = await emailChangeNotice, + }); + return builder; + } +} diff --git a/API/Services/Email/EmailServiceTemplates.cs b/Common/Services/Email/EmailServiceTemplates.cs similarity index 87% rename from API/Services/Email/EmailServiceTemplates.cs rename to Common/Services/Email/EmailServiceTemplates.cs index 80b2287b..4f5b9ca7 100644 --- a/API/Services/Email/EmailServiceTemplates.cs +++ b/Common/Services/Email/EmailServiceTemplates.cs @@ -1,4 +1,4 @@ -namespace OpenShock.API.Services.Email; +namespace OpenShock.Common.Services.Email; public sealed class EmailServiceTemplates { diff --git a/API/Services/Email/EmailServiceUtils.cs b/Common/Services/Email/EmailServiceUtils.cs similarity index 77% rename from API/Services/Email/EmailServiceUtils.cs rename to Common/Services/Email/EmailServiceUtils.cs index b596e19b..0877b628 100644 --- a/API/Services/Email/EmailServiceUtils.cs +++ b/Common/Services/Email/EmailServiceUtils.cs @@ -1,8 +1,8 @@ using System.Net.Mail; using MimeKit; -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Mailjet.Mail; -namespace OpenShock.API.Services.Email; +namespace OpenShock.Common.Services.Email; public static class EmailServiceUtils { diff --git a/API/Services/Email/EmailTemplate.cs b/Common/Services/Email/EmailTemplate.cs similarity index 95% rename from API/Services/Email/EmailTemplate.cs rename to Common/Services/Email/EmailTemplate.cs index 37ab285c..2ac7f234 100644 --- a/API/Services/Email/EmailTemplate.cs +++ b/Common/Services/Email/EmailTemplate.cs @@ -1,8 +1,8 @@ using System.Text.Encodings.Web; using Fluid; -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Mailjet.Mail; -namespace OpenShock.API.Services.Email; +namespace OpenShock.Common.Services.Email; public sealed class EmailTemplate { diff --git a/Common/Services/Email/IEmailSender.cs b/Common/Services/Email/IEmailSender.cs new file mode 100644 index 00000000..f5a0c0d3 --- /dev/null +++ b/Common/Services/Email/IEmailSender.cs @@ -0,0 +1,34 @@ +using OpenShock.Common.Services.Email.Mailjet.Mail; + +namespace OpenShock.Common.Services.Email; + +/// +/// Low-level email transport. Implementations (Mailjet, SMTP, none) render a template and hand it to +/// the upstream provider. A failed delivery must surface as an +/// so callers (notably the retry-queueing decorator) can decide whether to retry. +/// +/// This is the raw sender, it has no knowledge of the retry queue. Application code should instead +/// depend on the queueing email service, which wraps this with queue-on-failure behaviour. +/// +public interface IEmailSender +{ + /// + /// Account activation email sent when a user signs up. + /// + public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default); + + /// + /// Password reset email. + /// + public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default); + + /// + /// Email-verification email sent when a user changes their email address. + /// + public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default); + + /// + /// Informational notice sent to the previous email address when an email change is initiated. + /// + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default); +} diff --git a/API/Services/Email/Mailjet/Mail/Contact.cs b/Common/Services/Email/Mailjet/Mail/Contact.cs similarity index 91% rename from API/Services/Email/Mailjet/Mail/Contact.cs rename to Common/Services/Email/Mailjet/Mail/Contact.cs index afc664db..bd755e68 100644 --- a/API/Services/Email/Mailjet/Mail/Contact.cs +++ b/Common/Services/Email/Mailjet/Mail/Contact.cs @@ -1,7 +1,7 @@ using System.ComponentModel.DataAnnotations; using System.Diagnostics.CodeAnalysis; -namespace OpenShock.API.Services.Email.Mailjet.Mail; +namespace OpenShock.Common.Services.Email.Mailjet.Mail; public class Contact { diff --git a/API/Services/Email/Mailjet/Mail/DirectMail.cs b/Common/Services/Email/Mailjet/Mail/DirectMail.cs similarity index 78% rename from API/Services/Email/Mailjet/Mail/DirectMail.cs rename to Common/Services/Email/Mailjet/Mail/DirectMail.cs index 8d185c81..fb58f51c 100644 --- a/API/Services/Email/Mailjet/Mail/DirectMail.cs +++ b/Common/Services/Email/Mailjet/Mail/DirectMail.cs @@ -1,4 +1,4 @@ -namespace OpenShock.API.Services.Email.Mailjet.Mail; +namespace OpenShock.Common.Services.Email.Mailjet.Mail; public sealed class DirectMail { diff --git a/API/Services/Email/Mailjet/Mail/MailsWrap.cs b/Common/Services/Email/Mailjet/Mail/MailsWrap.cs similarity index 60% rename from API/Services/Email/Mailjet/Mail/MailsWrap.cs rename to Common/Services/Email/Mailjet/Mail/MailsWrap.cs index 3d6296e2..d922f5c8 100644 --- a/API/Services/Email/Mailjet/Mail/MailsWrap.cs +++ b/Common/Services/Email/Mailjet/Mail/MailsWrap.cs @@ -1,4 +1,4 @@ -namespace OpenShock.API.Services.Email.Mailjet.Mail; +namespace OpenShock.Common.Services.Email.Mailjet.Mail; public sealed class MailsWrap { diff --git a/API/Services/Email/Mailjet/MailjetEmailService.cs b/Common/Services/Email/Mailjet/MailjetEmailService.cs similarity index 65% rename from API/Services/Email/Mailjet/MailjetEmailService.cs rename to Common/Services/Email/Mailjet/MailjetEmailService.cs index 90063fe0..3565ba8d 100644 --- a/API/Services/Email/Mailjet/MailjetEmailService.cs +++ b/Common/Services/Email/Mailjet/MailjetEmailService.cs @@ -1,13 +1,13 @@ -using OpenShock.API.Options; -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email.Mailjet.Mail; using System.Net.Mime; using System.Text; using System.Text.Json; using OpenShock.Common.JsonSerialization; -namespace OpenShock.API.Services.Email.Mailjet; +namespace OpenShock.Common.Services.Email.Mailjet; -public sealed class MailjetEmailService : IEmailService, IDisposable +public sealed class MailjetEmailService : IEmailSender { private readonly HttpClient _httpClient; private readonly EmailServiceTemplates _templates; @@ -67,18 +67,35 @@ private async Task SendMails(DirectMail[] mails, CancellationToken cancellationT var json = JsonSerializer.Serialize(new MailsWrap { Messages = mails }, JsonOptions.Default); - var response = await _httpClient.PostAsync("send", - new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json), cancellationToken); - if (!response.IsSuccessStatusCode) + HttpResponseMessage response; + try { - _logger.LogError("Error sending mails. Got unsuccessful status code {StatusCode} for mails {@Mails} with error body {Body}", - response.StatusCode, mails, await response.Content.ReadAsStringAsync(cancellationToken)); + using var content = new StringContent(json, Encoding.UTF8, MediaTypeNames.Application.Json); + response = await _httpClient.PostAsync("send", content, cancellationToken); + } + catch (HttpRequestException ex) + { + // Connection-level failure (DNS, refused, reset, TLS). Always worth retrying. + throw new EmailDeliveryException(isTransient: true, "Failed to reach the Mailjet API", ex); } - else _logger.LogDebug("Successfully sent mail"); - } - public void Dispose() - { - _httpClient.Dispose(); + using (response) + { + if (response.IsSuccessStatusCode) + { + _logger.LogDebug("Successfully sent mail"); + return; + } + + var body = await response.Content.ReadAsStringAsync(cancellationToken); + _logger.LogError("Error sending mails. Got unsuccessful status code {StatusCode} for mails {@Mails} with error body {Body}", + response.StatusCode, mails, body); + + // Retry only on rate limiting (429) and server-side errors (5xx). Other 4xx (bad request, + // auth, rejected recipient) are permanent and re-sending would just fail again. + var statusCode = (int)response.StatusCode; + var isTransient = statusCode == 429 || statusCode >= 500; + throw new EmailDeliveryException(isTransient, $"Mailjet returned status code {statusCode}"); + } } } diff --git a/API/Services/Email/Mailjet/MailjetEmailServiceExtension.cs b/Common/Services/Email/Mailjet/MailjetEmailServiceExtension.cs similarity index 86% rename from API/Services/Email/Mailjet/MailjetEmailServiceExtension.cs rename to Common/Services/Email/Mailjet/MailjetEmailServiceExtension.cs index 31fe0758..030bc251 100644 --- a/API/Services/Email/Mailjet/MailjetEmailServiceExtension.cs +++ b/Common/Services/Email/Mailjet/MailjetEmailServiceExtension.cs @@ -1,9 +1,9 @@ -using OpenShock.API.Options; +using OpenShock.Common.Options; using System.Net.Http.Headers; using System.Text; using Microsoft.Extensions.Options; -namespace OpenShock.API.Services.Email.Mailjet; +namespace OpenShock.Common.Services.Email.Mailjet; public static class MailjetEmailServiceExtension { @@ -20,7 +20,7 @@ public static WebApplicationBuilder AddMailjetEmailService(this WebApplicationBu var options = section.Get() ?? throw new NullReferenceException("MailJetOptions is null!"); var basicAuthValue = Convert.ToBase64String(Encoding.UTF8.GetBytes($"{options.Key}:{options.Secret}")); - builder.Services.AddHttpClient(httpclient => + builder.Services.AddHttpClient(httpclient => { httpclient.BaseAddress = new Uri("https://api.mailjet.com/v3.1/"); httpclient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", basicAuthValue); diff --git a/API/Services/Email/NoneEmailService.cs b/Common/Services/Email/NoneEmailService.cs similarity index 91% rename from API/Services/Email/NoneEmailService.cs rename to Common/Services/Email/NoneEmailService.cs index e1908562..24a2485b 100644 --- a/API/Services/Email/NoneEmailService.cs +++ b/Common/Services/Email/NoneEmailService.cs @@ -1,13 +1,13 @@ -using OpenShock.API.Services.Email.Mailjet.Mail; +using OpenShock.Common.Services.Email.Mailjet.Mail; -namespace OpenShock.API.Services.Email; +namespace OpenShock.Common.Services.Email; /// /// This is a noop implementation of the email service. It does nothing. /// Consumers should properly handle when this service is used, so realistaically this should never be used. /// But we need it for DI satisfaction. /// -public class NoneEmailService : IEmailService +public class NoneEmailService : IEmailSender { private readonly ILogger _logger; diff --git a/Common/Services/Email/Queue/EmailQueueProcessor.cs b/Common/Services/Email/Queue/EmailQueueProcessor.cs new file mode 100644 index 00000000..d7d4b2de --- /dev/null +++ b/Common/Services/Email/Queue/EmailQueueProcessor.cs @@ -0,0 +1,207 @@ +using System.Text.Json; +using Microsoft.EntityFrameworkCore; +using OpenShock.Common.Constants; +using OpenShock.Common.Models; +using OpenShock.Common.OpenShockDb; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email.Mailjet.Mail; +using OpenShock.Common.Utils; + +namespace OpenShock.Common.Services.Email.Queue; + +/// +/// Processes due rows: for each, regenerates a fresh token (where applicable) +/// and resends through the raw . On success the row is deleted; on a transient +/// failure it is rescheduled with exponential backoff; on a permanent failure or once the attempt limit +/// is reached it is dropped. +/// +/// Scoped — the background worker resolves a fresh instance per scan. The raw sender is used directly so +/// a re-send failure reschedules the existing row rather than enqueueing a duplicate. +/// +public sealed class EmailQueueProcessor +{ + private const int MaxErrorLength = 1000; + + private enum DispatchOutcome + { + Sent, + Dropped + } + + private readonly IDbContextFactory _dbContextFactory; + private readonly IEmailSender _sender; + private readonly FrontendOptions _frontendOptions; + private readonly EmailQueueOptions _options; + private readonly ILogger _logger; + + public EmailQueueProcessor( + IDbContextFactory dbContextFactory, + IEmailSender sender, + FrontendOptions frontendOptions, + EmailQueueOptions options, + ILogger logger) + { + _dbContextFactory = dbContextFactory; + _sender = sender; + _frontendOptions = frontendOptions; + _options = options; + _logger = logger; + } + + public async Task ProcessDueItemsAsync(CancellationToken cancellationToken) + { + await using var db = await _dbContextFactory.CreateDbContextAsync(cancellationToken); + + var now = DateTime.UtcNow; + var due = await db.QueuedEmails + .Where(q => q.NextAttemptAt <= now) + .OrderBy(q => q.NextAttemptAt) + .Take(_options.BatchSize) + .ToListAsync(cancellationToken); + + if (due.Count == 0) return; + + _logger.LogDebug("Processing {Count} due queued email(s)", due.Count); + + foreach (var item in due) + { + cancellationToken.ThrowIfCancellationRequested(); + await ProcessItemAsync(db, item, cancellationToken); + } + } + + private async Task ProcessItemAsync(OpenShockContext db, QueuedEmail item, CancellationToken cancellationToken) + { + item.Attempts++; + + try + { + var outcome = await DispatchAsync(db, item, cancellationToken); + + db.QueuedEmails.Remove(item); + if (outcome == DispatchOutcome.Dropped) + _logger.LogInformation("Dropping queued {EmailType} email {Id}: target no longer needs it", item.Type, item.Id); + else + _logger.LogDebug("Sent queued {EmailType} email {Id} on attempt {Attempts}", item.Type, item.Id, item.Attempts); + } + catch (OperationCanceledException) + { + throw; + } + catch (EmailDeliveryException ex) when (!ex.IsTransient) + { + db.QueuedEmails.Remove(item); + _logger.LogError(ex, "Permanent failure retrying queued {EmailType} email {Id}; dropping", item.Type, item.Id); + } + catch (Exception ex) + { + if (item.Attempts >= _options.MaxAttempts) + { + db.QueuedEmails.Remove(item); + _logger.LogError(ex, "Giving up on queued {EmailType} email {Id} after {Attempts} attempts", item.Type, item.Id, item.Attempts); + } + else + { + item.NextAttemptAt = DateTime.UtcNow + _options.GetRetryDelay(item.Attempts); + item.LastError = Truncate(ex.Message); + _logger.LogWarning(ex, "Retry {Attempts} failed for queued {EmailType} email {Id}; next attempt at {NextAttemptAt:o}", + item.Attempts, item.Type, item.Id, item.NextAttemptAt); + } + } + + await db.SaveChangesAsync(cancellationToken); + } + + private Task DispatchAsync(OpenShockContext db, QueuedEmail item, CancellationToken cancellationToken) + => item.Type switch + { + QueuedEmailType.AccountActivation => DispatchActivationAsync(db, item, cancellationToken), + QueuedEmailType.EmailVerification => DispatchEmailVerificationAsync(db, item, cancellationToken), + QueuedEmailType.EmailChangeNotice => DispatchEmailChangeNoticeAsync(db, item, cancellationToken), + _ => throw new ArgumentOutOfRangeException(nameof(item), item.Type, "Unknown queued email type") + }; + + private async Task DispatchActivationAsync(OpenShockContext db, QueuedEmail item, CancellationToken cancellationToken) + { + var payload = item.Payload.RootElement.Deserialize() + ?? throw new InvalidOperationException("Activation payload could not be deserialized"); + + var user = await db.Users + .Include(u => u.UserDeactivation) + .Include(u => u.UserActivationRequest) + .FirstOrDefaultAsync(u => u.Id == payload.UserId, cancellationToken); + + // Nothing to do if the account is gone, already activated, or deactivated. + if (user is null || user.ActivatedAt is not null || user.UserDeactivation is not null) + return DispatchOutcome.Dropped; + + // Mint a fresh token and persist it before sending so the link in the email is the stored one. + var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); + if (user.UserActivationRequest is null) + { + user.UserActivationRequest = new UserActivationRequest + { + UserId = user.Id, + TokenHash = HashingUtils.HashToken(token), + EmailSendAttempts = 1 + }; + } + else + { + user.UserActivationRequest.TokenHash = HashingUtils.HashToken(token); + user.UserActivationRequest.EmailSendAttempts++; + } + + await db.SaveChangesAsync(cancellationToken); + + await _sender.ActivateAccount(new Contact(payload.Email, user.Name), + new Uri(_frontendOptions.BaseUrl, $"/activate?token={token}"), cancellationToken); + + return DispatchOutcome.Sent; + } + + private async Task DispatchEmailVerificationAsync(OpenShockContext db, QueuedEmail item, CancellationToken cancellationToken) + { + var payload = item.Payload.RootElement.Deserialize() + ?? throw new InvalidOperationException("Email verification payload could not be deserialized"); + + var validSince = DateTime.UtcNow - Duration.EmailChangeRequestLifetime; + + // Only resend for a still-pending, unexpired change whose security stamp hasn't rotated. + var change = await db.UserEmailChanges + .Include(c => c.User) + .FirstOrDefaultAsync(c => c.UserId == payload.UserId && c.NewEmail == payload.Email + && c.UsedAt == null && c.CreatedAt >= validSince + && c.SecurityStampAtCreate == c.User.SecurityStamp + && c.User.UserDeactivation == null && c.User.ActivatedAt != null, cancellationToken); + + if (change is null) return DispatchOutcome.Dropped; + + var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength); + change.TokenHash = HashingUtils.HashToken(token); + + await db.SaveChangesAsync(cancellationToken); + + await _sender.VerifyEmail(new Contact(payload.Email, change.User.Name), + new Uri(_frontendOptions.BaseUrl, $"/verify-email?token={token}"), cancellationToken); + + return DispatchOutcome.Sent; + } + + private async Task DispatchEmailChangeNoticeAsync(OpenShockContext db, QueuedEmail item, CancellationToken cancellationToken) + { + var payload = item.Payload.RootElement.Deserialize() + ?? throw new InvalidOperationException("Email change notice payload could not be deserialized"); + + // The notice carries no token; it's purely informational, so we just resend it as-is. + var user = await db.Users.FirstOrDefaultAsync(u => u.Id == payload.UserId, cancellationToken); + if (user is null) return DispatchOutcome.Dropped; + + await _sender.EmailChangeNotice(new Contact(payload.Email, user.Name), payload.NewEmail, cancellationToken); + + return DispatchOutcome.Sent; + } + + private static string Truncate(string value) + => value.Length <= MaxErrorLength ? value : value[..MaxErrorLength]; +} diff --git a/Common/Services/Email/Queue/QueuedEmailPayloads.cs b/Common/Services/Email/Queue/QueuedEmailPayloads.cs new file mode 100644 index 00000000..27521c0c --- /dev/null +++ b/Common/Services/Email/Queue/QueuedEmailPayloads.cs @@ -0,0 +1,18 @@ +namespace OpenShock.Common.Services.Email.Queue; + +/// +/// Type-specific JSONB payloads persisted on a . +/// These hold only the non-secret data the retry worker needs to look up the account and regenerate a +/// fresh token before resending — never the token or link itself. +/// +public static class QueuedEmailPayloads +{ + /// Payload for . + public sealed record Activation(Guid UserId, string Email); + + /// Payload for . + public sealed record EmailVerification(Guid UserId, string Email); + + /// Payload for . + public sealed record EmailChangeNotice(Guid UserId, string Email, string NewEmail); +} diff --git a/Common/Services/Email/Smtp/SmtpEmailService.cs b/Common/Services/Email/Smtp/SmtpEmailService.cs new file mode 100644 index 00000000..ac12a33d --- /dev/null +++ b/Common/Services/Email/Smtp/SmtpEmailService.cs @@ -0,0 +1,101 @@ +using MailKit.Net.Smtp; +using MailKit.Security; +using MimeKit; +using MimeKit.Text; +using OpenShock.Common.Options; +using OpenShock.Common.Services.Email.Mailjet.Mail; + +namespace OpenShock.Common.Services.Email.Smtp; + +public sealed class SmtpEmailService : IEmailSender +{ + private readonly EmailServiceTemplates _templates; + private readonly SmtpOptions _options; + private readonly MailboxAddress _sender; + private readonly ILogger _logger; + + public SmtpEmailService( + EmailServiceTemplates templates, + SmtpOptions options, + MailOptions.MailSenderContact sender, + ILogger logger + ) + { + _templates = templates; + _options = options; + _sender = sender.ToMailAddress(); + _logger = logger; + } + + public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default) + => SendMail(to, _templates.AccountActivation, new { To = to, ActivationLink = activationLink }, cancellationToken); + + /// + public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default) + => SendMail(to, _templates.PasswordReset, new { To = to, ResetLink = resetLink }, cancellationToken); + + /// + public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default) + => SendMail(to, _templates.EmailVerification, new { To = to, VerifyLink = verificationLink }, cancellationToken); + + /// + public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default) + => SendMail(to, _templates.EmailChangeNotice, new { To = to, NewEmail = newEmail }, cancellationToken); + + private async Task SendMail(Contact to, EmailTemplate template, T data, CancellationToken cancellationToken = default) + { + _logger.LogDebug("Sending email"); + var (subject, htmlBody) = await template.RenderAsync(data); + + using var message = new MimeMessage + { + From = { _sender }, + Sender = _sender, + To = { to.ToMailAddress() }, + Subject = subject, + Body = new TextPart(TextFormat.Html) { Text = htmlBody } + }; + + try + { + _logger.LogTrace("Creating smtp client and connecting..."); + using var smtpClient = new SmtpClient(); + if (!_options.VerifyCertificate) + { + smtpClient.ServerCertificateValidationCallback = (sender, certificate, chain, errors) => true; + smtpClient.CheckCertificateRevocation = false; + } + + await smtpClient.ConnectAsync(_options.Host, _options.Port, _options.EnableSsl, cancellationToken); + _logger.LogTrace("Authenticating..."); + if (smtpClient.Capabilities.HasFlag(SmtpCapabilities.Authentication)) + await smtpClient.AuthenticateAsync(_options.Username, _options.Password, cancellationToken); + + _logger.LogTrace("Smtp client connected, sending email..."); + + await smtpClient.SendAsync(message, cancellationToken); + await smtpClient.DisconnectAsync(true, cancellationToken); + _logger.LogTrace("Sent email"); + } + catch (OperationCanceledException) + { + throw; + } + catch (SmtpCommandException ex) + { + // SMTP 5xx is a permanent rejection (bad recipient, message refused); 4xx is temporary + // (mailbox busy, service not available) and worth retrying. + var isTransient = (int)ex.StatusCode < 500; + throw new EmailDeliveryException(isTransient, $"SMTP command failed ({ex.StatusCode}): {ex.Message}", ex); + } + catch (AuthenticationException ex) + { + throw new EmailDeliveryException(isTransient: false, "SMTP authentication failed", ex); + } + catch (Exception ex) + { + // Protocol errors, socket failures and timeouts are transient by nature. + throw new EmailDeliveryException(isTransient: true, "Failed to send email over SMTP", ex); + } + } +} \ No newline at end of file diff --git a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs b/Common/Services/Email/Smtp/SmtpEmailServiceExtension.cs similarity index 79% rename from API/Services/Email/Smtp/SmtpEmailServiceExtension.cs rename to Common/Services/Email/Smtp/SmtpEmailServiceExtension.cs index a4be55f4..d8863521 100644 --- a/API/Services/Email/Smtp/SmtpEmailServiceExtension.cs +++ b/Common/Services/Email/Smtp/SmtpEmailServiceExtension.cs @@ -1,7 +1,7 @@ using Microsoft.Extensions.Options; -using OpenShock.API.Options; +using OpenShock.Common.Options; -namespace OpenShock.API.Services.Email.Smtp; +namespace OpenShock.Common.Services.Email.Smtp; public static class SmtpEmailServiceExtension { @@ -14,7 +14,7 @@ public static WebApplicationBuilder AddSmtpEmailService(this WebApplicationBuild builder.Services.AddSingleton, SmtpOptionsValidator>(); builder.Services.AddSingleton(sp => sp.GetRequiredService>().Value); - builder.Services.AddSingleton(); + builder.Services.AddSingleton(); return builder; } diff --git a/API/SmtpTemplates/AccountActivation.liquid b/Common/SmtpTemplates/AccountActivation.liquid similarity index 100% rename from API/SmtpTemplates/AccountActivation.liquid rename to Common/SmtpTemplates/AccountActivation.liquid diff --git a/API/SmtpTemplates/EmailChangeNotice.liquid b/Common/SmtpTemplates/EmailChangeNotice.liquid similarity index 100% rename from API/SmtpTemplates/EmailChangeNotice.liquid rename to Common/SmtpTemplates/EmailChangeNotice.liquid diff --git a/API/SmtpTemplates/EmailVerification.liquid b/Common/SmtpTemplates/EmailVerification.liquid similarity index 100% rename from API/SmtpTemplates/EmailVerification.liquid rename to Common/SmtpTemplates/EmailVerification.liquid diff --git a/API/SmtpTemplates/PasswordReset.liquid b/Common/SmtpTemplates/PasswordReset.liquid similarity index 100% rename from API/SmtpTemplates/PasswordReset.liquid rename to Common/SmtpTemplates/PasswordReset.liquid diff --git a/Cron/Jobs/ProcessEmailQueueJob.cs b/Cron/Jobs/ProcessEmailQueueJob.cs new file mode 100644 index 00000000..3d9876d0 --- /dev/null +++ b/Cron/Jobs/ProcessEmailQueueJob.cs @@ -0,0 +1,25 @@ +using OpenShock.Common.Services.Email.Queue; +using OpenShock.Cron.Attributes; + +namespace OpenShock.Cron.Jobs; + +/// +/// Drains the email retry queue: resends emails that previously failed to reach the upstream provider, +/// regenerating a fresh token where needed. Each row respects its own NextAttemptAt backoff, so +/// running every minute simply picks up whatever has become due. +/// +[CronJob("* * * * *")] // Every minute (https://crontab.guru/) +public sealed class ProcessEmailQueueJob +{ + private readonly EmailQueueProcessor _processor; + + public ProcessEmailQueueJob(EmailQueueProcessor processor) + { + _processor = processor; + } + + public Task Execute(CancellationToken cancellationToken = default) + { + return _processor.ProcessDueItemsAsync(cancellationToken); + } +} diff --git a/Cron/Program.cs b/Cron/Program.cs index 43b5cbb2..1f67bddc 100644 --- a/Cron/Program.cs +++ b/Cron/Program.cs @@ -2,6 +2,8 @@ using Hangfire.PostgreSql; using OpenShock.Common; using OpenShock.Common.Extensions; +using OpenShock.Common.Services.Email; +using OpenShock.Common.Services.Email.Queue; using OpenShock.Cron; using OpenShock.Cron.Utils; using OpenShock.Common.Swagger; @@ -10,12 +12,17 @@ var redisOptions = builder.RegisterRedisOptions(); var databaseOptions = builder.RegisterDatabaseOptions(); +builder.RegisterFrontendOptions(); // Needed to build activation / verification links when resending builder.RegisterMetricsOptions(); builder.Services.AddOpenShockMemDB(redisOptions); builder.Services.AddOpenShockDB(databaseOptions); builder.Services.AddOpenShockServices(); +// Email retry queue: the raw sender plus the processor the ProcessEmailQueueJob drains. +await builder.AddOpenShockEmailSender(); +builder.Services.AddScoped(); + builder.Services.AddHangfire(hangfire => hangfire.UsePostgreSqlStorage(c => c.UseNpgsqlConnection(databaseOptions.Conn)));