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