diff --git a/API.IntegrationTests/Tests/EmailOutboxPersistenceTests.cs b/API.IntegrationTests/Tests/EmailOutboxPersistenceTests.cs
new file mode 100644
index 00000000..5755e190
--- /dev/null
+++ b/API.IntegrationTests/Tests/EmailOutboxPersistenceTests.cs
@@ -0,0 +1,100 @@
+using Microsoft.EntityFrameworkCore;
+using Microsoft.Extensions.DependencyInjection;
+using OpenShock.API.IntegrationTests.Helpers;
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.API.IntegrationTests.Tests;
+
+///
+/// Database round-trip coverage for the email_outbox row against real Postgres: that every
+/// (including the new sending/skipped enum values) and the new
+/// attempt/next-attempt columns persist and read back, and that the delivery job's claim predicate
+/// (the enum filter used by the FOR UPDATE SKIP LOCKED query) selects the right rows. The pure
+/// transition logic is covered by the Cron unit tests; this guards the persistence mapping the unit
+/// tests can't.
+///
+public sealed class EmailOutboxPersistenceTests
+{
+ [ClassDataSource(Shared = SharedType.PerTestSession)]
+ public required WebApplicationFactory WebApplicationFactory { get; init; }
+
+ [Test]
+ public async Task EveryStatus_AndAttemptColumns_RoundTripThroughPostgres()
+ {
+ var factory = WebApplicationFactory.Services.GetRequiredService>();
+
+ foreach (var status in Enum.GetValues())
+ {
+ Guid id;
+ await using (var db = await factory.CreateDbContextAsync())
+ {
+ var message = EmailOutboxMessage.Create(
+ EmailType.PasswordReset, TestHelper.UniqueEmail("outbox-persist"), "Persist",
+ new Dictionary { ["k"] = "v" });
+ message.Status = status;
+ message.AttemptCount = 3;
+ message.LastError = "boom";
+ message.CoalesceKey = "pwreset:round-trip";
+ db.EmailOutbox.Add(message);
+ await db.SaveChangesAsync();
+ id = message.Id;
+ }
+
+ await using var verifyDb = await factory.CreateDbContextAsync();
+ var loaded = await verifyDb.EmailOutbox.AsNoTracking().FirstAsync(m => m.Id == id);
+
+ await Assert.That(loaded.Status).IsEqualTo(status);
+ await Assert.That(loaded.AttemptCount).IsEqualTo(3);
+ await Assert.That(loaded.LastError).IsEqualTo("boom");
+ await Assert.That(loaded.CoalesceKey).IsEqualTo("pwreset:round-trip");
+ }
+ }
+
+ [Test]
+ public async Task ClaimPredicate_SelectsDuePendingAndLapsedSending_NotTerminalOrFuture()
+ {
+ var factory = WebApplicationFactory.Services.GetRequiredService>();
+ var recipient = TestHelper.UniqueEmail("outbox-claim");
+ var past = DateTime.UtcNow - TimeSpan.FromMinutes(5);
+ var future = DateTime.UtcNow + TimeSpan.FromHours(1);
+
+ var duePending = await SeedAsync(factory, recipient, EmailStatus.Pending, past);
+ var lapsedSending = await SeedAsync(factory, recipient, EmailStatus.Sending, past);
+ await SeedAsync(factory, recipient, EmailStatus.Pending, future); // scheduled retry, not yet due
+ await SeedAsync(factory, recipient, EmailStatus.Sent, past); // terminal
+
+ await using var db = await factory.CreateDbContextAsync();
+
+ // The delivery job's claim predicate (enum filter + dueness), scoped to this test's recipient so
+ // it never touches rows from other tests and needs no FOR UPDATE.
+ var claimed = await db.EmailOutbox
+ .FromSql(
+ $"""
+ SELECT * FROM email_outbox
+ WHERE recipient = {recipient}
+ AND next_attempt_at <= now()
+ AND (status = {EmailStatus.Pending} OR status = {EmailStatus.Sending})
+ """)
+ .AsNoTracking()
+ .Select(m => m.Id)
+ .ToListAsync();
+
+ await Assert.That(claimed).Contains(duePending);
+ await Assert.That(claimed).Contains(lapsedSending);
+ await Assert.That(claimed.Count).IsEqualTo(2);
+ }
+
+ private static async Task SeedAsync(IDbContextFactory factory, string recipient, EmailStatus status, DateTime nextAttemptAt)
+ {
+ await using var db = await factory.CreateDbContextAsync();
+ var message = EmailOutboxMessage.Create(
+ EmailType.PasswordReset, recipient, "Claim",
+ new Dictionary { ["k"] = "v" });
+ message.Status = status;
+ message.NextAttemptAt = nextAttemptAt;
+ db.EmailOutbox.Add(message);
+ await db.SaveChangesAsync();
+ return message.Id;
+ }
+}
diff --git a/API.IntegrationTests/Tests/MailTests.cs b/API.IntegrationTests/Tests/MailTests.cs
index 8f8cd793..7aa6a2f4 100644
--- a/API.IntegrationTests/Tests/MailTests.cs
+++ b/API.IntegrationTests/Tests/MailTests.cs
@@ -1,18 +1,20 @@
using System.Net;
-using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using OpenShock.API.IntegrationTests.Helpers;
+using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
namespace OpenShock.API.IntegrationTests.Tests;
///
-/// Tests that verify emails are actually delivered via SMTP to Mailpit.
-/// Each test uses a unique recipient address via so Mailpit
-/// lookups never collide with other tests in the session.
+/// The API's responsibility for transactional email is to write the correct
+/// row (and its business row) atomically - it never sends mail. These tests assert exactly that: the right
+/// outbox row is enqueued (type, recipient, coalesce key, payload) or, for rejected requests, that nothing
+/// is enqueued. Actual delivery, lazy token minting, the emailed-link flows, and newest-wins coalescing are
+/// the Cron host's job and are covered by Cron.IntegrationTests.
///
-public sealed partial class MailTests
+public sealed class MailTests
{
[ClassDataSource(Shared = SharedType.PerTestSession)]
public required WebApplicationFactory WebApplicationFactory { get; init; }
@@ -20,11 +22,10 @@ public sealed partial class MailTests
// --- Account Activation ---
[Test]
- public async Task V2Signup_SendsAccountActivationEmail()
+ public async Task V2Signup_EnqueuesActivationOutbox()
{
var email = TestHelper.UniqueEmail("mail-activation");
var username = TestHelper.UniqueUsername("mailactivation");
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
using var client = WebApplicationFactory.CreateClient();
var response = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
@@ -34,53 +35,17 @@ public async Task V2Signup_SendsAccountActivationEmail()
email,
turnstileResponse = "valid-token"
}));
-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
- var message = await mailpit.WaitForMessageAsync(email);
- await Assert.That(message).IsNotNull();
- await Assert.That(message!.To?.Select(c => c.Address)).Contains(email);
- }
-
- [Test]
- public async Task ActivationFlow_ViaEmailLink_ActivatesAccount()
- {
- var email = TestHelper.UniqueEmail("mail-activate-flow");
- var username = TestHelper.UniqueUsername("mailactivateflow");
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
- using var client = WebApplicationFactory.CreateClient();
-
- // Sign up — this triggers an activation email
- var signupResponse = await client.PostAsync("/2/account/signup", TestHelper.JsonContent(new
- {
- username,
- password = "SecurePassword123#",
- email,
- turnstileResponse = "valid-token"
- }));
- await Assert.That(signupResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Wait for and retrieve the activation email
- var message = await mailpit.WaitForMessageAsync(email);
- await Assert.That(message).IsNotNull();
-
- var fullMessage = await mailpit.GetMessageAsync(message!.Id);
- await Assert.That(fullMessage).IsNotNull();
-
- // Extract the activation token from the link in the email HTML
- var token = ExtractQueryParam(fullMessage!.Html, "token");
- await Assert.That(token).IsNotNull().And.IsNotEmpty();
-
- // Use the token to activate the account
- var activateResponse = await client.PostAsync($"/1/account/activate?token={token}", null);
- await Assert.That(activateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Confirm the user is now activated in the DB
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService();
- var user = await db.Users.FirstOrDefaultAsync(u => u.Email == email);
- await Assert.That(user).IsNotNull();
- await Assert.That(user!.ActivatedAt).IsNotNull();
+ var user = await db.Users.AsNoTracking().FirstAsync(u => u.Email == email);
+
+ var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email);
+ await Assert.That(row.Type).IsEqualTo(EmailType.AccountActivation);
+ await Assert.That(row.Status).IsEqualTo(EmailStatus.Pending);
+ await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.AccountActivation(user.Id));
+ await Assert.That(row.Payload[EmailOutboxPayloadKeys.UserId]).IsEqualTo(user.Id.ToString());
}
// --- Password Reset ---
@@ -108,13 +73,11 @@ public async Task ResetPasswordAlias_Retired_Returns410Gone()
}
[Test]
- public async Task V2PasswordReset_SendsPasswordResetEmail()
+ public async Task V2PasswordReset_EnqueuesResetOutbox()
{
var email = TestHelper.UniqueEmail("mail-pwreset-v2");
var username = TestHelper.UniqueUsername("mailpwresetv2");
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
-
- await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
+ var userId = await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
using var client = WebApplicationFactory.CreateClient();
var response = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new
@@ -122,77 +85,71 @@ public async Task V2PasswordReset_SendsPasswordResetEmail()
email,
turnstileResponse = "valid-token"
}));
-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.OK);
- var message = await mailpit.WaitForMessageAsync(email);
- await Assert.That(message).IsNotNull();
- await Assert.That(message!.To?.Select(c => c.Address)).Contains(email);
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var reset = await db.UserPasswordResets.AsNoTracking().SingleAsync(r => r.UserId == userId);
+
+ var row = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == email);
+ await Assert.That(row.Type).IsEqualTo(EmailType.PasswordReset);
+ await Assert.That(row.Status).IsEqualTo(EmailStatus.Pending);
+ await Assert.That(row.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.PasswordReset(userId));
+ await Assert.That(row.Payload[EmailOutboxPayloadKeys.PasswordResetId]).IsEqualTo(reset.Id.ToString());
}
[Test]
- public async Task PasswordResetFlow_ViaEmailLink_ChangesPassword()
+ public async Task PasswordResetComplete_LegacyRecoverRoute_Returns410Gone()
{
- var email = TestHelper.UniqueEmail("mail-pwreset-flow");
- var username = TestHelper.UniqueUsername("mailpwresetflow");
- const string newPassword = "NewSecurePassword456#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
+ using var client = WebApplicationFactory.CreateClient();
- await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
+ var response = await client.PostAsync(
+ $"/1/account/recover/{Guid.CreateVersion7()}/somesecret",
+ TestHelper.JsonContent(new { password = "LegacyNewPassword456#" }));
- using var client = WebApplicationFactory.CreateClient();
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
+ }
- // Initiate password reset
- var resetResponse = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
- await Assert.That(resetResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ [Test]
+ public async Task PasswordResetCheck_LegacyHeadRecoverRoute_Returns410Gone()
+ {
+ using var client = WebApplicationFactory.CreateClient();
- // Wait for reset email and extract the link
- var message = await mailpit.WaitForMessageAsync(email);
- await Assert.That(message).IsNotNull();
+ var response = await client.SendAsync(new HttpRequestMessage(
+ HttpMethod.Head, $"/1/account/recover/{Guid.CreateVersion7()}/somesecret"));
- var fullMessage = await mailpit.GetMessageAsync(message!.Id);
- await Assert.That(fullMessage).IsNotNull();
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
+ }
- // Link format: /#/account/password/recover/{id}/{secret}
- var (resetId, secret) = ExtractPasswordResetParams(fullMessage!.Html);
- await Assert.That(resetId).IsNotNull().And.IsNotEmpty();
- await Assert.That(secret).IsNotNull().And.IsNotEmpty();
+ [Test]
+ public async Task PasswordResetCheck_InvalidToken_Returns404()
+ {
+ var email = TestHelper.UniqueEmail("mail-pwreset-check-invalid");
+ var username = TestHelper.UniqueUsername("mailpwresetcheckinvalid");
+ await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
- // Verify the reset token is valid
- var checkResponse = await client.GetAsync($"/1/account/password-reset/{resetId}/{secret}");
- await Assert.That(checkResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ using var client = WebApplicationFactory.CreateClient();
- // Complete the reset with a new password
- var completeResponse = await client.PostAsync(
- $"/1/account/password-reset/{resetId}/{secret}/complete",
- TestHelper.JsonContent(new { password = newPassword }));
- await Assert.That(completeResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var bogusId = Guid.CreateVersion7();
+ const string bogusSecret = "thisisnotarealtokenatallzz";
- // Confirm we can log in with the new password
- var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
- {
- usernameOrEmail = email,
- password = newPassword,
- turnstileResponse = "valid-token"
- }));
- await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var response = await client.GetAsync($"/1/account/password-reset/{bogusId}/{bogusSecret}");
+ await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
}
// --- Change Email ---
[Test]
- public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail()
+ public async Task ChangeEmail_EnqueuesVerificationAndNotice()
{
- var oldEmail = TestHelper.UniqueEmail("mail-chgemail-flow-old");
- var newEmail = TestHelper.UniqueEmail("mail-chgemail-flow-new");
- var username = TestHelper.UniqueUsername("mailchgemailflow");
+ var oldEmail = TestHelper.UniqueEmail("mail-chgemail-notice-old");
+ var newEmail = TestHelper.UniqueEmail("mail-chgemail-notice-new");
+ var username = TestHelper.UniqueUsername("mailchgemailnotice");
const string password = "SecurePassword123#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password);
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
- // Initiate the email change
var initiateResponse = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new
{
currentPassword = password,
@@ -200,49 +157,30 @@ public async Task ChangeEmailFlow_ViaEmailLink_ChangesEmail()
}));
await Assert.That(initiateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
- // The verification email goes to the NEW address
- var message = await mailpit.WaitForMessageAsync(newEmail);
- await Assert.That(message).IsNotNull();
-
- var fullMessage = await mailpit.GetMessageAsync(message!.Id);
- await Assert.That(fullMessage).IsNotNull();
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var change = await db.UserEmailChanges.AsNoTracking().SingleAsync(c => c.UserId == user.Id);
- var token = ExtractQueryParam(fullMessage!.Html, "token");
- await Assert.That(token).IsNotNull().And.IsNotEmpty();
+ // Verification to the NEW address: coalesced per user, references the change row by id.
+ var verification = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == newEmail);
+ await Assert.That(verification.Type).IsEqualTo(EmailType.EmailVerification);
+ await Assert.That(verification.Status).IsEqualTo(EmailStatus.Pending);
+ await Assert.That(verification.CoalesceKey).IsEqualTo(EmailOutboxCoalesceKeys.EmailVerification(user.Id));
+ await Assert.That(verification.Payload[EmailOutboxPayloadKeys.EmailChangeId]).IsEqualTo(change.Id.ToString());
- // Email is not changed yet
- await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
- {
- var db = scope.ServiceProvider.GetRequiredService();
- var beforeUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id);
- await Assert.That(beforeUser.Email).IsEqualTo(oldEmail);
- }
-
- // Use the token to complete the change
- using var anonClient = WebApplicationFactory.CreateClient();
- var verifyResponse = await anonClient.PostAsync($"/1/account/email-change/verify?token={token}", null);
- await Assert.That(verifyResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Email is now updated
- await using (var scope = WebApplicationFactory.Services.CreateAsyncScope())
- {
- var db = scope.ServiceProvider.GetRequiredService();
- var afterUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id);
- await Assert.That(afterUser.Email).IsEqualTo(newEmail);
- }
-
- // Re-using the same token must now fail
- var replayResponse = await anonClient.PostAsync($"/1/account/email-change/verify?token={token}", null);
- await Assert.That(replayResponse.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
+ // Notice to the OLD address: always delivered (no coalesce key), carries the new address as data.
+ var notice = await db.EmailOutbox.AsNoTracking().SingleAsync(m => m.Recipient == oldEmail);
+ await Assert.That(notice.Type).IsEqualTo(EmailType.EmailChangeNotice);
+ await Assert.That(notice.CoalesceKey).IsNull();
+ await Assert.That(notice.Payload[EmailOutboxPayloadKeys.NewEmail]).IsEqualTo(newEmail);
}
[Test]
- public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail()
+ public async Task ChangeEmail_WrongPassword_Returns403_AndEnqueuesNothing()
{
var oldEmail = TestHelper.UniqueEmail("mail-chgemail-badpwd-old");
var newEmail = TestHelper.UniqueEmail("mail-chgemail-badpwd-new");
var username = TestHelper.UniqueUsername("mailchgemailbadpwd");
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, "CorrectPassword123#");
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
@@ -252,60 +190,21 @@ public async Task ChangeEmail_WrongPassword_Returns403_AndSendsNoEmail()
currentPassword = "WrongPassword!",
email = newEmail
}));
-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Forbidden);
- // Neither the verification (new address) nor the change notice (old address) should have been dispatched.
- var verification = await mailpit.WaitForMessageAsync(newEmail, TimeSpan.FromSeconds(2));
- await Assert.That(verification).IsNull();
- var notice = await mailpit.WaitForMessageAsync(oldEmail, TimeSpan.FromSeconds(2));
- await Assert.That(notice).IsNull();
- }
-
- [Test]
- public async Task ChangeEmailFlow_SendsNoticeToOldEmail()
- {
- var oldEmail = TestHelper.UniqueEmail("mail-chgemail-notice-old");
- var newEmail = TestHelper.UniqueEmail("mail-chgemail-notice-new");
- var username = TestHelper.UniqueUsername("mailchgemailnotice");
- const string password = "SecurePassword123#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
-
- var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password);
- using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
-
- var initiateResponse = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new
- {
- currentPassword = password,
- email = newEmail
- }));
- await Assert.That(initiateResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Verification email lands at the new address...
- var verification = await mailpit.WaitForMessageAsync(newEmail);
- await Assert.That(verification).IsNotNull();
- await Assert.That(verification!.To?.Select(c => c.Address)).Contains(newEmail);
-
- // ...and a notice lands at the OLD address, mentioning the new address.
- var notice = await mailpit.WaitForMessageAsync(oldEmail);
- await Assert.That(notice).IsNotNull();
- await Assert.That(notice!.To?.Select(c => c.Address)).Contains(oldEmail);
-
- var noticeFull = await mailpit.GetMessageAsync(notice.Id);
- await Assert.That(noticeFull).IsNotNull();
- await Assert.That(noticeFull!.Html).Contains(newEmail);
-
- // The notice must not contain a verification link — it's informational only.
- await Assert.That(noticeFull.Html).DoesNotContain("token=");
+ await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
+ var db = scope.ServiceProvider.GetRequiredService();
+ var enqueued = await db.EmailOutbox.AsNoTracking()
+ .CountAsync(m => m.Recipient == newEmail || m.Recipient == oldEmail);
+ await Assert.That(enqueued).IsEqualTo(0);
}
[Test]
- public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail()
+ public async Task ChangeEmail_Unchanged_Returns400_AndEnqueuesNothing()
{
var email = TestHelper.UniqueEmail("mail-chgemail-unchanged");
var username = TestHelper.UniqueUsername("mailchgemailunchanged");
const string password = "SecurePassword123#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, email, password);
using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
@@ -315,167 +214,12 @@ public async Task ChangeEmail_Unchanged_Returns400_AndSendsNoEmail()
currentPassword = password,
email
}));
-
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
- // No emails at all — neither verification nor notice.
- var any = await mailpit.WaitForMessageAsync(email, TimeSpan.FromSeconds(2));
- await Assert.That(any).IsNull();
- }
-
- [Test]
- public async Task PasswordResetComplete_LegacyRecoverRoute_Returns410Gone()
- {
- using var client = WebApplicationFactory.CreateClient();
-
- var response = await client.PostAsync(
- $"/1/account/recover/{Guid.CreateVersion7()}/somesecret",
- TestHelper.JsonContent(new { password = "LegacyNewPassword456#" }));
-
- await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
- }
-
- [Test]
- public async Task PasswordResetCheck_LegacyHeadRecoverRoute_Returns410Gone()
- {
- using var client = WebApplicationFactory.CreateClient();
-
- var response = await client.SendAsync(new HttpRequestMessage(
- HttpMethod.Head, $"/1/account/recover/{Guid.CreateVersion7()}/somesecret"));
-
- await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Gone);
- }
-
- [Test]
- public async Task PasswordResetCheck_InvalidToken_Returns404()
- {
- var email = TestHelper.UniqueEmail("mail-pwreset-check-invalid");
- var username = TestHelper.UniqueUsername("mailpwresetcheckinvalid");
- await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
-
- using var client = WebApplicationFactory.CreateClient();
-
- var bogusId = Guid.CreateVersion7();
- const string bogusSecret = "thisisnotarealtokenatallzz";
-
- var response = await client.GetAsync($"/1/account/password-reset/{bogusId}/{bogusSecret}");
- await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
- }
-
- [Test]
- public async Task ChangeEmailFlow_SecondPendingRequest_InvalidatedAfterFirstCompletes()
- {
- var oldEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-old");
- var firstNewEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-first");
- var secondNewEmail = TestHelper.UniqueEmail("mail-chgemail-sibling-second");
- var username = TestHelper.UniqueUsername("mailchgemailsibling");
- const string password = "SecurePassword123#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
-
- var user = await TestHelper.CreateAndLoginUser(WebApplicationFactory, username, oldEmail, password);
- using var client = TestHelper.CreateAuthenticatedClient(WebApplicationFactory, user.SessionToken);
-
- // Initiate two concurrent email change requests
- var firstInit = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new
- {
- currentPassword = password,
- email = firstNewEmail
- }));
- await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- var secondInit = await client.PostAsync("/1/account/email-change", TestHelper.JsonContent(new
- {
- currentPassword = password,
- email = secondNewEmail
- }));
- await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- var firstMessage = await mailpit.WaitForMessageAsync(firstNewEmail);
- var secondMessage = await mailpit.WaitForMessageAsync(secondNewEmail);
- await Assert.That(firstMessage).IsNotNull();
- await Assert.That(secondMessage).IsNotNull();
-
- var firstFull = await mailpit.GetMessageAsync(firstMessage!.Id);
- var secondFull = await mailpit.GetMessageAsync(secondMessage!.Id);
- var firstToken = ExtractQueryParam(firstFull!.Html, "token");
- var secondToken = ExtractQueryParam(secondFull!.Html, "token");
- await Assert.That(firstToken).IsNotNull().And.IsNotEmpty();
- await Assert.That(secondToken).IsNotNull().And.IsNotEmpty();
-
- using var anonClient = WebApplicationFactory.CreateClient();
-
- // Complete the first request — email becomes firstNewEmail
- var firstVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={firstToken}", null);
- await Assert.That(firstVerify.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Second pending request is now invalid: its SecurityStampAtCreate snapshot no longer matches User.SecurityStamp.
- var secondVerify = await anonClient.PostAsync($"/1/account/email-change/verify?token={secondToken}", null);
- await Assert.That(secondVerify.StatusCode).IsEqualTo(HttpStatusCode.BadRequest);
-
await using var scope = WebApplicationFactory.Services.CreateAsyncScope();
var db = scope.ServiceProvider.GetRequiredService();
- var afterUser = await db.Users.AsNoTracking().FirstAsync(u => u.Id == user.Id);
- await Assert.That(afterUser.Email).IsEqualTo(firstNewEmail);
- }
-
- [Test]
- public async Task PasswordResetFlow_SecondPendingResetInvalidatedAfterFirstCompletes()
- {
- var email = TestHelper.UniqueEmail("mail-pwreset-sibling");
- var username = TestHelper.UniqueUsername("mailpwresetsibling");
- const string firstNewPassword = "FirstNewPassword123#";
- const string secondNewPassword = "SecondNewPassword456#";
- using var mailpit = WebApplicationFactory.CreateMailpitHelper();
-
- await TestHelper.CreateUserInDb(WebApplicationFactory, username, email, "OldPassword123#");
-
- using var client = WebApplicationFactory.CreateClient();
-
- // Fire two reset requests back-to-back, then wait for both emails to land.
- var firstInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
- await Assert.That(firstInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- var secondInit = await client.PostAsync("/2/account/password-reset", TestHelper.JsonContent(new { email, turnstileResponse = "valid-token" }));
- await Assert.That(secondInit.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- var messages = await mailpit.WaitForMessagesAsync(email, minCount: 2);
- await Assert.That(messages.Count).IsGreaterThanOrEqualTo(2);
-
- // We don't care which of the two emails came from which request — the scenario is just
- // "two valid pending resets exist; completing either must invalidate the other". Pick the
- // first two distinct reset-id/secret pairs we see and call them A and B.
- var fullA = await mailpit.GetMessageAsync(messages[0].Id);
- var fullB = await mailpit.GetMessageAsync(messages[1].Id);
- var (resetIdA, secretA) = ExtractPasswordResetParams(fullA!.Html);
- var (resetIdB, secretB) = ExtractPasswordResetParams(fullB!.Html);
- await Assert.That(resetIdA).IsNotNull().And.IsNotEmpty();
- await Assert.That(resetIdB).IsNotNull().And.IsNotEmpty();
- await Assert.That(resetIdA).IsNotEqualTo(resetIdB);
-
- // Complete reset A
- var completeA = await client.PostAsync(
- $"/1/account/password-reset/{resetIdA}/{secretA}/complete",
- TestHelper.JsonContent(new { password = firstNewPassword }));
- await Assert.That(completeA.StatusCode).IsEqualTo(HttpStatusCode.OK);
-
- // Reset B (sibling) must no longer be usable
- var checkB = await client.GetAsync(
- $"/1/account/password-reset/{resetIdB}/{secretB}");
- await Assert.That(checkB.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
-
- var completeB = await client.PostAsync(
- $"/1/account/password-reset/{resetIdB}/{secretB}/complete",
- TestHelper.JsonContent(new { password = secondNewPassword }));
- await Assert.That(completeB.StatusCode).IsEqualTo(HttpStatusCode.NotFound);
-
- // Password from the winning reset works
- var loginResponse = await client.PostAsync("/2/account/login", TestHelper.JsonContent(new
- {
- usernameOrEmail = email,
- password = firstNewPassword,
- turnstileResponse = "valid-token"
- }));
- await Assert.That(loginResponse.StatusCode).IsEqualTo(HttpStatusCode.OK);
+ var enqueued = await db.EmailOutbox.AsNoTracking().CountAsync(m => m.Recipient == email);
+ await Assert.That(enqueued).IsEqualTo(0);
}
[Test]
@@ -499,43 +243,4 @@ public async Task ChangeEmail_AlreadyInUse_Returns409()
await Assert.That(response.StatusCode).IsEqualTo(HttpStatusCode.Conflict);
}
-
- // --- Helpers ---
-
- ///
- /// Extracts a query parameter value from a URL embedded in HTML (first <a href> containing the param).
- ///
- private static string? ExtractQueryParam(string html, string paramName)
- {
- var hrefMatch = HrefRegex().Match(html);
- while (hrefMatch.Success)
- {
- var href = hrefMatch.Groups[1].Value;
- if (Uri.TryCreate(href, UriKind.Absolute, out var uri))
- {
- var query = System.Web.HttpUtility.ParseQueryString(uri.Query);
- var value = query[paramName];
- if (value is not null) return value;
- }
- hrefMatch = hrefMatch.NextMatch();
- }
- return null;
- }
-
- ///
- /// Extracts the (passwordResetId, secret) pair from the password-reset URL embedded in email HTML.
- /// URL pattern: /account/password/recover/{guid}/{secret}
- ///
- private static (string? ResetId, string? Secret) ExtractPasswordResetParams(string html)
- {
- var match = PasswordResetPathRegex().Match(html);
- if (!match.Success) return (null, null);
- return (match.Groups[1].Value, match.Groups[2].Value);
- }
-
- [GeneratedRegex(@"href=""([^""]+)""", RegexOptions.IgnoreCase)]
- private static partial Regex HrefRegex();
-
- [GeneratedRegex(@"/account/password/recover/([0-9a-fA-F\-]+)/([A-Za-z0-9]+)", RegexOptions.IgnoreCase)]
- private static partial Regex PasswordResetPathRegex();
}
diff --git a/API.IntegrationTests/WebApplicationFactory.cs b/API.IntegrationTests/WebApplicationFactory.cs
index 536cb6dd..289c548b 100644
--- a/API.IntegrationTests/WebApplicationFactory.cs
+++ b/API.IntegrationTests/WebApplicationFactory.cs
@@ -7,15 +7,17 @@
using Microsoft.Extensions.Http;
using Microsoft.Extensions.Options;
using OpenShock.API.IntegrationTests.Docker;
-using OpenShock.API.IntegrationTests.Helpers;
using OpenShock.API.IntegrationTests.HttpMessageHandlers;
using Serilog;
using Serilog.Events;
-using TUnit.Core.Interfaces;
namespace OpenShock.API.IntegrationTests;
-public class WebApplicationFactory : WebApplicationFactory, IAsyncInitializer
+// These tests exercise the API only. The API's job for transactional email is to write the
+// EmailOutboxMessage row (and its business row) atomically - it never sends mail. Delivery, token
+// minting, and newest-wins coalescing belong to the Cron host and are covered by Cron.IntegrationTests,
+// so this factory deliberately boots no Cron host, no SMTP server, and no delivery loop.
+public class WebApplicationFactory : WebApplicationFactory
{
[ClassDataSource(Shared = SharedType.PerTestSession)]
public required InMemoryDatabase PostgreSql { get; init; }
@@ -23,17 +25,6 @@ public class WebApplicationFactory : WebApplicationFactory, IAsyncIniti
[ClassDataSource(Shared = SharedType.PerTestSession)]
public required InMemoryRedis Redis { get; init; }
- [ClassDataSource(Shared = SharedType.PerTestSession)]
- public required TestMailServer Mailpit { get; init; }
-
- public MailpitHelper CreateMailpitHelper() => new(Mailpit.ApiBaseUrl);
-
- public Task InitializeAsync()
- {
- _ = Server;
- return Task.CompletedTask;
- }
-
protected override void ConfigureClient(HttpClient client)
{
base.ConfigureClient(client);
@@ -69,14 +60,6 @@ protected override void ConfigureWebHost(IWebHostBuilder builder)
{ "OPENSHOCK__FRONTEND__SHORTURL", "https://openshock.app" },
{ "OPENSHOCK__FRONTEND__COOKIEDOMAIN", "openshock.app,localhost" },
- { "OPENSHOCK__MAIL__TYPE", "SMTP" },
- { "OPENSHOCK__MAIL__SENDER__EMAIL", "system@openshock.org" },
- { "OPENSHOCK__MAIL__SENDER__NAME", "OpenShock" },
- { "OPENSHOCK__MAIL__SMTP__HOST", Mailpit.SmtpHost },
- { "OPENSHOCK__MAIL__SMTP__PORT", Mailpit.SmtpPort.ToString() },
- { "OPENSHOCK__MAIL__SMTP__ENABLESSL", "false" },
- { "OPENSHOCK__MAIL__SMTP__VERIFYCERTIFICATE", "false" },
-
{ "OPENSHOCK__TURNSTILE__ENABLED", "true" },
{ "OPENSHOCK__TURNSTILE__SECRETKEY", "turnstile-secret-key" },
{ "OPENSHOCK__TURNSTILE__SITEKEY", "turnstile-site-key" },
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/Program.cs b/API/Program.cs
index 8a92776f..83719d63 100644
--- a/API/Program.cs
+++ b/API/Program.cs
@@ -4,7 +4,6 @@
using OpenShock.API.Realtime;
using OpenShock.API.Services.Account;
using OpenShock.API.Services.DeviceUpdate;
-using OpenShock.API.Services.Email;
using OpenShock.API.Services.LCGNodeProvisioner;
using OpenShock.API.Services.OAuthConnection;
using OpenShock.API.Services.Token;
@@ -109,7 +108,6 @@ static void DefaultOptions(RemoteAuthenticationOptions options, string provider)
builder.AddSwaggerExt();
builder.AddCloudflareTurnstileService();
-await builder.AddEmailService();
//services.AddHealthChecks().AddCheck("database");
diff --git a/API/Services/Account/AccountService.cs b/API/Services/Account/AccountService.cs
index e92926b0..1ab0968a 100644
--- a/API/Services/Account/AccountService.cs
+++ b/API/Services/Account/AccountService.cs
@@ -3,12 +3,10 @@
using Npgsql;
using OneOf;
using OneOf.Types;
-using OpenShock.API.Services.Email;
-using OpenShock.API.Services.Email.Mailjet.Mail;
using OpenShock.Common.Constants;
using OpenShock.Common.Models;
using OpenShock.Common.OpenShockDb;
-using OpenShock.Common.Options;
+using OpenShock.Common.Services.RedisPubSub;
using OpenShock.Common.Services.Session;
using OpenShock.Common.Utils;
using OpenShock.Common.Validation;
@@ -21,29 +19,52 @@ namespace OpenShock.API.Services.Account;
public sealed class AccountService : IAccountService
{
private readonly OpenShockContext _db;
- private readonly IEmailService _emailService;
+ private readonly IRedisPubService _redisPubService;
private readonly ISessionService _sessionService;
private readonly ILogger _logger;
- private readonly FrontendOptions _frontendConfig;
///
/// DI Constructor
///
///
- ///
+ /// Used to notify the email outbox delivery job that mail was enqueued.
///
///
- ///
- public AccountService(OpenShockContext db, IEmailService emailService,
- ISessionService sessionService, ILogger logger, FrontendOptions options)
+ public AccountService(OpenShockContext db, IRedisPubService redisPubService,
+ ISessionService sessionService, ILogger logger)
{
_db = db;
- _emailService = emailService;
+ _redisPubService = redisPubService;
_logger = logger;
- _frontendConfig = options;
_sessionService = sessionService;
}
+ ///
+ /// Seeds a random token hash for a freshly created request row. The plaintext is discarded
+ /// immediately: the email outbox delivery job mints the real token (and overwrites this hash) when it
+ /// sends, so this value is never the one delivered. It exists only to keep the column populated.
+ ///
+ private static string SeedTokenHash()
+ => HashingUtils.HashToken(CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength));
+
+ ///
+ /// Best-effort nudge to the email outbox delivery job that a message was enqueued. Deliberately
+ /// swallows failures: the outbox row is already committed, so a dropped notification only delays
+ /// delivery to the next scheduled delivery sweep, and a transient Redis hiccup must not fail a request
+ /// whose work already succeeded.
+ ///
+ private async Task NotifyEmailOutboxAsync()
+ {
+ try
+ {
+ await _redisPubService.SendEmailOutboxPending();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex, "Failed to notify the email outbox delivery job; delivery will fall back to the scheduled sweep");
+ }
+ }
+
private async Task IsUserNameBlacklisted(string username)
{
await foreach (var entry in _db.UserNameBlacklists.AsNoTracking().AsAsyncEnumerable())
@@ -101,18 +122,19 @@ public async Task, AccountWithEmailOrUsernameExists>> Create
var user = accountCreate.AsT0.Value;
- var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength);
-
+ // The real activation token is minted by the outbox delivery job at send time; here we record the
+ // request (with a seeded hash) and durably enqueue the email.
user.UserActivationRequest = new UserActivationRequest
{
UserId = user.Id,
- TokenHash = HashingUtils.HashToken(token)
+ TokenHash = SeedTokenHash()
};
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username));
+
await _db.SaveChangesAsync();
+ await NotifyEmailOutboxAsync();
- await _emailService.ActivateAccount(new Contact(email, username),
- new Uri(_frontendConfig.BaseUrl, $"/activate?token={token}"));
return new Success(user);
}
@@ -143,8 +165,6 @@ public async Task, AccountWithEmailOrUsernameExists>> Create
await using var tx = await _db.Database.BeginTransactionAsync();
- string? activationToken = null;
-
try
{
var creationTime = DateTime.UtcNow;
@@ -162,18 +182,19 @@ public async Task, AccountWithEmailOrUsernameExists>> Create
_db.Users.Add(user);
await _db.SaveChangesAsync();
- // If email isn't trusted, create an activation request (email verification)
+ // If email isn't trusted, create an activation request and durably enqueue the activation
+ // email. The token itself is minted by the outbox delivery job at send time.
if (!isEmailTrusted)
{
- activationToken = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength);
-
user.UserActivationRequest = new UserActivationRequest
{
UserId = user.Id,
- TokenHash = HashingUtils.HashToken(activationToken),
+ TokenHash = SeedTokenHash(),
CreatedAt = creationTime
};
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForAccountActivation(user.Id, email, username));
+
await _db.SaveChangesAsync();
}
@@ -191,13 +212,10 @@ public async Task, AccountWithEmailOrUsernameExists>> Create
await tx.CommitAsync();
- // Send verification email only after a successful commit
- if (!isEmailTrusted && activationToken is not null)
+ // Notify the outbox delivery job only after a successful commit.
+ if (!isEmailTrusted)
{
- await _emailService.ActivateAccount(
- new Contact(email, username),
- new Uri(_frontendConfig.BaseUrl, $"/activate?token={activationToken}")
- );
+ await NotifyEmailOutboxAsync();
}
return new Success(user);
@@ -412,19 +430,20 @@ public async Task= 3) return new TooManyPasswordResets();
- var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength);
+ // The reset token is minted lazily by the outbox delivery job at send time (and re-minted on any
+ // resend), so no usable reset link is ever stored. Here we only record the request and
+ // durably enqueue the email.
var passwordReset = new UserPasswordReset
{
Id = Guid.CreateVersion7(),
UserId = user.User.Id,
- TokenHash = HashingUtils.HashToken(token),
+ TokenHash = SeedTokenHash(),
SecurityStampAtCreate = user.User.SecurityStamp
};
_db.UserPasswordResets.Add(passwordReset);
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForPasswordReset(passwordReset.Id, user.User.Id, user.User.Email, user.User.Name));
await _db.SaveChangesAsync();
-
- await _emailService.PasswordReset(new Contact(user.User.Email, user.User.Name),
- new Uri(_frontendConfig.BaseUrl, $"/#/account/password/recover/{passwordReset.Id}/{token}"));
+ await NotifyEmailOutboxAsync();
return new Success();
}
@@ -574,38 +593,29 @@ public async Task x.Email == lowerCaseEmail))
return new EmailAlreadyInUse();
- var token = CryptoUtils.RandomAlphaNumericString(AuthConstants.GeneratedTokenLength);
+ // The verification token is minted lazily by the outbox delivery job at send time, so no usable
+ // verification link is ever stored.
var emailChange = new UserEmailChange
{
Id = Guid.CreateVersion7(),
UserId = data.User.Id,
OldEmail = data.User.Email,
NewEmail = lowerCaseEmail,
- TokenHash = HashingUtils.HashToken(token),
+ TokenHash = SeedTokenHash(),
SecurityStampAtCreate = data.User.SecurityStamp
};
+ _db.UserEmailChanges.Add(emailChange);
- // Dispatch the verification email *before* committing the row. If the mail service throws
- // (provider outage, transient network failure), the exception propagates and the row is
- // never inserted, the user can simply retry without burning a pending-count slot.
- await _emailService.VerifyEmail(new Contact(lowerCaseEmail, data.User.Name),
- new Uri(_frontendConfig.BaseUrl, $"/verify-email?token={token}"));
+ // Durably enqueue both emails: the verification link to the new address, and an informational
+ // notice to the previous address so the legitimate owner sees the change request even if the
+ // session/password used to start it was compromised. The outbox guarantees delivery with
+ // retries; this replaces the previous best-effort inline sends. Committing the row even if
+ // mail later fails is intentional - the request is durable and the delivery job keeps retrying.
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForEmailVerification(emailChange.Id, data.User.Id, lowerCaseEmail, data.User.Name));
+ _db.EmailOutbox.Add(EmailOutboxMessage.ForEmailChangeNotice(lowerCaseEmail, data.User.Email, data.User.Name));
- _db.UserEmailChanges.Add(emailChange);
await _db.SaveChangesAsync();
-
- // Notify the previous address so the legitimate owner sees the change request even if
- // the session/password used to start it was compromised. Best-effort: the verification
- // email has already been dispatched and the row is committed, so a failure here must not
- // unwind the request.
- try
- {
- await _emailService.EmailChangeNotice(new Contact(data.User.Email, data.User.Name), lowerCaseEmail);
- }
- catch (Exception ex)
- {
- _logger.LogWarning(ex, "Failed to send email-change notice to previous address for user {UserId}", data.User.Id);
- }
+ await NotifyEmailOutboxAsync();
return new Success();
}
diff --git a/API/Services/Email/IEmailService.cs b/API/Services/Email/IEmailService.cs
deleted file mode 100644
index 12aee102..00000000
--- a/API/Services/Email/IEmailService.cs
+++ /dev/null
@@ -1,43 +0,0 @@
-using OpenShock.API.Services.Email.Mailjet.Mail;
-
-namespace OpenShock.API.Services.Email;
-
-public interface IEmailService
-{
- ///
- /// When a user uses the signup form we send this email to let them activate their account
- ///
- ///
- ///
- ///
- ///
- public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default);
-
- ///
- /// Send a password reset email
- ///
- ///
- ///
- ///
- ///
- 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
- ///
- ///
- ///
- ///
- ///
- public Task VerifyEmail(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 old email address being notified.
- /// The new email address that was requested.
- ///
- public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default);
-}
diff --git a/API/Services/Email/Mailjet/MailjetEmailService.cs b/API/Services/Email/Mailjet/MailjetEmailService.cs
deleted file mode 100644
index 90063fe0..00000000
--- a/API/Services/Email/Mailjet/MailjetEmailService.cs
+++ /dev/null
@@ -1,84 +0,0 @@
-using OpenShock.API.Options;
-using OpenShock.API.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;
-
-public sealed class MailjetEmailService : IEmailService, IDisposable
-{
- private readonly HttpClient _httpClient;
- private readonly EmailServiceTemplates _templates;
- private readonly MailOptions.MailSenderContact _sender;
- private readonly ILogger _logger;
-
- public MailjetEmailService(
- HttpClient httpClient,
- EmailServiceTemplates templates,
- MailOptions.MailSenderContact sender,
- ILogger logger
- )
- {
- _httpClient = httpClient;
- _templates = templates;
- _sender = sender;
- _logger = logger;
- }
-
- #region Interface methods
-
- public async Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default)
- {
- var (subject, htmlBody) = await _templates.AccountActivation.RenderAsync(new { To = to, ActivationLink = activationLink });
- await SendMail(to, subject, htmlBody, cancellationToken);
- }
-
- ///
- public async Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default)
- {
- var (subject, htmlBody) = await _templates.PasswordReset.RenderAsync(new { To = to, ResetLink = resetLink });
- await SendMail(to, subject, htmlBody, cancellationToken);
- }
-
- ///
- public async Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default)
- {
- var (subject, htmlBody) = await _templates.EmailVerification.RenderAsync(new { To = to, VerifyLink = verificationLink });
- await SendMail(to, subject, htmlBody, cancellationToken);
- }
-
- ///
- public async Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default)
- {
- var (subject, htmlBody) = await _templates.EmailChangeNotice.RenderAsync(new { To = to, NewEmail = newEmail });
- await SendMail(to, subject, htmlBody, cancellationToken);
- }
-
- #endregion
-
- private Task SendMail(Contact to, string subject, string htmlBody, CancellationToken cancellationToken = default)
- => SendMails([new DirectMail { From = _sender, To = [to], Subject = subject, HTMLPart = htmlBody }], cancellationToken);
-
- private async Task SendMails(DirectMail[] mails, CancellationToken cancellationToken = default)
- {
- if (_logger.IsEnabled(LogLevel.Debug)) _logger.LogDebug("Sending mails {@Mails}", mails);
-
- 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)
- {
- _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));
- }
- else _logger.LogDebug("Successfully sent mail");
- }
-
- public void Dispose()
- {
- _httpClient.Dispose();
- }
-}
diff --git a/API/Services/Email/NoneEmailService.cs b/API/Services/Email/NoneEmailService.cs
deleted file mode 100644
index e1908562..00000000
--- a/API/Services/Email/NoneEmailService.cs
+++ /dev/null
@@ -1,42 +0,0 @@
-using OpenShock.API.Services.Email.Mailjet.Mail;
-
-namespace OpenShock.API.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
-{
- private readonly ILogger _logger;
-
- public NoneEmailService(ILogger logger)
- {
- _logger = logger;
- }
-
- public Task ActivateAccount(Contact to, Uri activationLink, CancellationToken cancellationToken = default)
- {
- _logger.LogError("Account activation email not sent, this is a noop implementation of the email service");
- return Task.CompletedTask;
- }
-
- public Task PasswordReset(Contact to, Uri resetLink, CancellationToken cancellationToken = default)
- {
- _logger.LogError("Password reset email not sent, this is a noop implementation of the email service");
- return Task.CompletedTask;
- }
-
- public Task VerifyEmail(Contact to, Uri verificationLink, CancellationToken cancellationToken = default)
- {
- _logger.LogError("Email verification email not sent, this is a noop implementation of the email service");
- return Task.CompletedTask;
- }
-
- public Task EmailChangeNotice(Contact to, string newEmail, CancellationToken cancellationToken = default)
- {
- _logger.LogError("Email change notice not sent, this is a noop implementation of the email service");
- return Task.CompletedTask;
- }
-}
\ No newline at end of file
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.Tests/OpenShockDb/EmailOutboxMessageTests.cs b/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs
new file mode 100644
index 00000000..0a2a6b9c
--- /dev/null
+++ b/Common.Tests/OpenShockDb/EmailOutboxMessageTests.cs
@@ -0,0 +1,38 @@
+using OpenShock.Common.Models;
+using OpenShock.Common.OpenShockDb;
+
+namespace OpenShock.Common.Tests.OpenShockDb;
+
+public class EmailOutboxMessageTests
+{
+ [Test]
+ public async Task Create_InitializesAsPendingWithGivenFields()
+ {
+ var payload = new Dictionary
+ {
+ [EmailOutboxPayloadKeys.PasswordResetId] = "11111111-1111-1111-1111-111111111111"
+ };
+
+ var message = EmailOutboxMessage.Create(EmailType.PasswordReset, "user@example.com", "User", payload);
+
+ await Assert.That(message.Status).IsEqualTo(EmailStatus.Pending);
+ await Assert.That(message.Type).IsEqualTo(EmailType.PasswordReset);
+ await Assert.That(message.Recipient).IsEqualTo("user@example.com");
+ await Assert.That(message.RecipientName).IsEqualTo("User");
+ await Assert.That(message.SentAt).IsNull();
+ await Assert.That(message.FailedAt).IsNull();
+ await Assert.That(message.Id).IsNotEqualTo(Guid.Empty);
+ await Assert.That(message.Payload[EmailOutboxPayloadKeys.PasswordResetId])
+ .IsEqualTo("11111111-1111-1111-1111-111111111111");
+ }
+
+ [Test]
+ public async Task Create_AllowsNullRecipientName()
+ {
+ var message = EmailOutboxMessage.Create(EmailType.EmailChangeNotice, "old@example.com", null,
+ new Dictionary { [EmailOutboxPayloadKeys.NewEmail] = "new@example.com" });
+
+ await Assert.That(message.RecipientName).IsNull();
+ await Assert.That(message.Payload[EmailOutboxPayloadKeys.NewEmail]).IsEqualTo("new@example.com");
+ }
+}
diff --git a/Common/Constants/HardLimits.cs b/Common/Constants/HardLimits.cs
index 6688536c..3cbc0660 100644
--- a/Common/Constants/HardLimits.cs
+++ b/Common/Constants/HardLimits.cs
@@ -41,6 +41,9 @@ public static class HardLimits
public const int PasswordHashMaxLength = 100;
+ public const int EmailOutboxLastErrorMaxLength = 1024;
+ public const int EmailOutboxCoalesceKeyMaxLength = 128;
+
public const int UserEmailChangeSecretMaxLength = 128;
public const int UserActivationRequestSecretMaxLength = 128;
public const int PasswordResetSecretMaxLength = 100;
diff --git a/Common/Migrations/20260630210423_AddEmailOutbox.Designer.cs b/Common/Migrations/20260630210423_AddEmailOutbox.Designer.cs
new file mode 100644
index 00000000..32ebce5b
--- /dev/null
+++ b/Common/Migrations/20260630210423_AddEmailOutbox.Designer.cs
@@ -0,0 +1,1599 @@
+//
+using System;
+using System.Collections.Generic;
+using System.Net;
+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("20260630210423_AddEmailOutbox")]
+ partial class AddEmailOutbox
+ {
+ ///
+ 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, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" });
+ 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, "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.EmailOutboxMessage", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AttemptCount")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("attempt_count");
+
+ b.Property("CoalesceKey")
+ .HasMaxLength(128)
+ .HasColumnType("character varying(128)")
+ .HasColumnName("coalesce_key");
+
+ b.Property("CreatedAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("created_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property("FailedAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("failed_at");
+
+ b.Property("LastError")
+ .HasMaxLength(1024)
+ .HasColumnType("character varying(1024)")
+ .HasColumnName("last_error");
+
+ b.Property("NextAttemptAt")
+ .ValueGeneratedOnAdd()
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("next_attempt_at")
+ .HasDefaultValueSql("CURRENT_TIMESTAMP");
+
+ b.Property>("Payload")
+ .IsRequired()
+ .HasColumnType("jsonb")
+ .HasColumnName("payload");
+
+ b.Property("Recipient")
+ .IsRequired()
+ .HasMaxLength(320)
+ .HasColumnType("character varying(320)")
+ .HasColumnName("recipient");
+
+ b.Property("RecipientName")
+ .HasMaxLength(32)
+ .HasColumnType("character varying(32)")
+ .HasColumnName("recipient_name");
+
+ b.Property("SentAt")
+ .HasColumnType("timestamp with time zone")
+ .HasColumnName("sent_at");
+
+ b.Property("Status")
+ .HasColumnType("email_status")
+ .HasColumnName("status");
+
+ b.Property("Type")
+ .HasColumnType("email_type")
+ .HasColumnName("type");
+
+ b.HasKey("Id")
+ .HasName("email_outbox_pkey");
+
+ b.HasIndex("CoalesceKey");
+
+ b.HasIndex("Recipient");
+
+ b.HasIndex("Status", "NextAttemptAt");
+
+ b.ToTable("email_outbox", (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.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/20260630210423_AddEmailOutbox.cs b/Common/Migrations/20260630210423_AddEmailOutbox.cs
new file mode 100644
index 00000000..8f0ca811
--- /dev/null
+++ b/Common/Migrations/20260630210423_AddEmailOutbox.cs
@@ -0,0 +1,110 @@
+using System;
+using System.Collections.Generic;
+using Microsoft.EntityFrameworkCore.Migrations;
+using OpenShock.Common.Models;
+
+#nullable disable
+
+namespace OpenShock.Common.Migrations
+{
+ ///
+ public partial class AddEmailOutbox : 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:email_status", "pending,sending,sent,failed,skipped")
+ .Annotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice")
+ .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:role_type", "support,staff,admin,system")
+ .OldAnnotation("Npgsql:Enum:shocker_model_type", "caiXianlin,petTrainer,petrainer998DR,wellturnT330");
+
+ migrationBuilder.CreateTable(
+ name: "email_outbox",
+ columns: table => new
+ {
+ id = table.Column(type: "uuid", nullable: false),
+ type = table.Column(type: "email_type", nullable: false),
+ recipient = table.Column(type: "character varying(320)", maxLength: 320, nullable: false),
+ recipient_name = table.Column(type: "character varying(32)", maxLength: 32, nullable: true),
+ payload = table.Column>(type: "jsonb", nullable: false),
+ coalesce_key = table.Column(type: "character varying(128)", maxLength: 128, nullable: true),
+ status = table.Column(type: "email_status", nullable: false),
+ attempt_count = table.Column(type: "integer", nullable: false, defaultValue: 0),
+ next_attempt_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
+ last_error = table.Column(type: "character varying(1024)", maxLength: 1024, nullable: true),
+ created_at = table.Column(type: "timestamp with time zone", nullable: false, defaultValueSql: "CURRENT_TIMESTAMP"),
+ sent_at = table.Column(type: "timestamp with time zone", nullable: true),
+ failed_at = table.Column(type: "timestamp with time zone", nullable: true)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("email_outbox_pkey", x => x.id);
+ });
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_outbox_coalesce_key",
+ table: "email_outbox",
+ column: "coalesce_key");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_outbox_recipient",
+ table: "email_outbox",
+ column: "recipient");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_email_outbox_status_next_attempt_at",
+ table: "email_outbox",
+ columns: new[] { "status", "next_attempt_at" });
+ }
+
+ ///
+ protected override void Down(MigrationBuilder migrationBuilder)
+ {
+ migrationBuilder.DropTable(
+ name: "email_outbox");
+
+ 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:email_status", "pending,sending,sent,failed,skipped")
+ .OldAnnotation("Npgsql:Enum:email_type", "account_activation,password_reset,email_verification,email_change_notice")
+ .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");
+ }
+ }
+}
diff --git a/Common/Migrations/OpenShockContextModelSnapshot.cs b/Common/Migrations/OpenShockContextModelSnapshot.cs
index 81f0c618..ee0dea15 100644
--- a/Common/Migrations/OpenShockContextModelSnapshot.cs
+++ b/Common/Migrations/OpenShockContextModelSnapshot.cs
@@ -21,12 +21,14 @@ 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" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_limit_mode", new[] { "clamp", "lerp" });
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "control_type", new[] { "sound", "vibrate", "shock", "stop" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_status", new[] { "pending", "sending", "sent", "failed", "skipped" });
+ NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "email_type", new[] { "account_activation", "password_reset", "email_verification", "email_change_notice" });
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" });
@@ -426,6 +428,85 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("discord_webhooks", (string)null);
});
+ modelBuilder.Entity("OpenShock.Common.OpenShockDb.EmailOutboxMessage", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("uuid")
+ .HasColumnName("id");
+
+ b.Property("AttemptCount")
+ .IsConcurrencyToken()
+ .ValueGeneratedOnAdd()
+ .HasColumnType("integer")
+ .HasDefaultValue(0)
+ .HasColumnName("attempt_count");
+
+ b.Property