Adds transactional outbox and email verification

Implements outbox pattern for reliable message delivery
Adds email verification flow with Postmark integration
Enhances account registration with secure token generation

Introduces background processing for asynchronous email sending
Implements database-level notification mechanism for message processing
This commit is contained in:
Janus C. H. Knudsen 2026-01-10 11:13:33 +01:00
parent 88812177a9
commit 54b057886c
35 changed files with 1174 additions and 358 deletions

View file

@ -0,0 +1,9 @@
using PlanTempus.Core.CommandQueries;
namespace PlanTempus.Components.Accounts.ConfirmEmail;
public class ConfirmEmailCommand : Command
{
public required string Email { get; set; }
public required string Token { get; set; }
}

View file

@ -0,0 +1,40 @@
#nullable enable
using Insight.Database;
using PlanTempus.Core.CommandQueries;
using PlanTempus.Core.Database;
namespace PlanTempus.Components.Accounts.ConfirmEmail;
public class ConfirmEmailHandler(IDatabaseOperations databaseOperations) : ICommandHandler<ConfirmEmailCommand>
{
public async Task<CommandResponse> Handle(ConfirmEmailCommand command)
{
using var db = databaseOperations.CreateScope(nameof(ConfirmEmailHandler));
var sql = @"
UPDATE system.accounts
SET email_confirmed = true
WHERE email = @Email AND security_stamp = @Token";
var affectedRows = await db.Connection.ExecuteSqlAsync(sql, new
{
command.Email,
command.Token
});
if (affectedRows == 0)
{
throw new InvalidTokenException();
}
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
}
}
public class InvalidTokenException : Exception
{
public InvalidTokenException() : base("Invalid or expired verification token")
{
}
}

View file

@ -1,26 +1,30 @@
using Insight.Database;
using Microsoft.ApplicationInsights;
using Npgsql;
using PlanTempus.Components.Accounts.Exceptions;
using PlanTempus.Core;
using PlanTempus.Core.CommandQueries;
using PlanTempus.Core.Database;
using PlanTempus.Core.Outbox;
namespace PlanTempus.Components.Accounts.Create
{
public class CreateAccountHandler(
IDatabaseOperations databaseOperations,
ISecureTokenizer secureTokenizer) : ICommandHandler<CreateAccountCommand>
ISecureTokenizer secureTokenizer,
IOutboxService outboxService) : ICommandHandler<CreateAccountCommand>
{
public async Task<CommandResponse> Handle(CreateAccountCommand command)
{
using var db = databaseOperations.CreateScope(nameof(CreateAccountHandler));
using var transaction = db.Connection.BeginTransaction();
try
{
var securityStamp = Guid.NewGuid().ToString("N");
var sql = @"
INSERT INTO system.accounts(email , password_hash, security_stamp, email_confirmed,
access_failed_count, lockout_enabled,
is_active)
INSERT INTO system.accounts(email, password_hash, security_stamp, email_confirmed,
access_failed_count, lockout_enabled, is_active)
VALUES(@Email, @PasswordHash, @SecurityStamp, @EmailConfirmed,
@AccessFailedCount, @LockoutEnabled, @IsActive)
RETURNING id, created_at, email, is_active";
@ -29,22 +33,36 @@ namespace PlanTempus.Components.Accounts.Create
{
command.Email,
PasswordHash = secureTokenizer.TokenizeText(command.Password),
SecurityStamp = Guid.NewGuid().ToString("N"),
SecurityStamp = securityStamp,
EmailConfirmed = false,
AccessFailedCount = 0,
LockoutEnabled = false,
command.IsActive,
});
await outboxService.EnqueueAsync(
OutboxMessageTypes.VerificationEmail,
new VerificationEmailPayload
{
Email = command.Email,
UserName = command.Email,
Token = securityStamp
},
db.Connection,
transaction);
transaction.Commit();
return new CommandResponse(command.CorrelationId, command.GetType().Name, command.TransactionId);
}
catch (PostgresException ex) when (ex.SqlState == "23505" && ex.ConstraintName.Equals("accounts_email_key", StringComparison.InvariantCultureIgnoreCase))
{
transaction.Rollback();
db.Error(ex);
throw new EmailAlreadyRegistreredException();
}
catch (Exception ex)
{
transaction.Rollback();
db.Error(ex);
throw;
}