diff --git a/docs/web/architecture.md b/docs/web/architecture.md
index 07d465df..e11f7c1b 100644
--- a/docs/web/architecture.md
+++ b/docs/web/architecture.md
@@ -77,7 +77,7 @@ flowchart TB
subgraph SHARED["Shared Projects"]
direction LR
SH1["Shared.Contracts
ResponseBody envelope"]
- SH2["Shared.Application
ValidationBehavior,
cross-slice email commands"]
+ SH2["Shared.Application
ValidationBehavior"]
end
subgraph DOMAIN["Domain Layer"]
@@ -98,7 +98,7 @@ flowchart TB
DB[("Database (SQL Server)")]
HOST -- "discovers controllers via
AddApplicationPart" --> SLICES
- S1 -- "SendRegistrationEmailCommand
(via Shared.Application contract)" --> S3
+ S3 == "ProjectReference
(handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification)" ==> S1
S1 -- "ProjectReference
(avatar upload)" --> S5
SLICES --> SHARED
@@ -172,8 +172,10 @@ brewery photo upload command) rather than bound to an HTTP route directly.
`Infrastructure.Email.Templates` for Emails, `Infrastructure.FileUpload` for
PhotoUpload)
- `Shared.Contracts`, `Shared.Application`
-- No other `Features.*` project, with one exception: `Features.Users` references
- `Features.PhotoUpload` directly for avatar uploads
+- No other `Features.*` project, with two exceptions: `Features.Users`
+ references `Features.PhotoUpload` directly for avatar uploads, and
+ `Features.Emails` references `Features.Users` directly to handle
+ `UserRegisteredNotification` and `ConfirmationEmailResendRequestedNotification`
**Rules**:
@@ -191,7 +193,7 @@ needs it, or because duplicating it four times would be worse than sharing it
| Project | Purpose |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `Shared.Contracts` | `ResponseBody`/`ResponseBody`, the API response envelope every controller returns |
-| `Shared.Application` | `ValidationBehavior` (the MediatR pipeline behavior that runs FluentValidation before a handler executes) and the cross-slice email commands (`SendRegistrationEmailCommand`, `SendResendConfirmationEmailCommand`) |
+| `Shared.Application` | `ValidationBehavior`, the MediatR pipeline behavior that runs FluentValidation before a handler executes |
**Rules**:
diff --git a/docs/web/diagrams/auth/registration.mmd b/docs/web/diagrams/auth/registration.mmd
index 9403f83c..83b34edf 100644
--- a/docs/web/diagrams/auth/registration.mmd
+++ b/docs/web/diagrams/auth/registration.mmd
@@ -127,9 +127,9 @@ sequenceDiagram
TokenSvc-->>RegHandler: Confirmation Token
deactivate TokenSvc
- RegHandler->>Mediator: Send(SendRegistrationEmailCommand) (Shared.Application cross-slice contract)
+ RegHandler->>Mediator: Publish(UserRegisteredNotification)
activate Mediator
- Note right of Mediator: Routed to Features.Emails' SendRegistrationEmailHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
+ Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via UserRegisteredNotificationHandler — the second of two cross-slice dispatches in this flow (profile creation is the first)
Mediator->>EmailDispatcher: SendRegistrationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
@@ -147,15 +147,15 @@ sequenceDiagram
SMTP-->>EmailDispatcher: Success / throws
deactivate SMTP
deactivate EmailDispatcher
- deactivate Mediator
alt Email sent successfully
- RegHandler->>RegHandler: emailSent = true
- else mediator.Send throws
- RegHandler->>RegHandler: catch(Exception), log to stderr, emailSent = false
+ Note right of Mediator: UserRegisteredNotificationHandler completes normally
+ else EmailDispatcher throws
+ Note right of Mediator: UserRegisteredNotificationHandler catches and logs the exception — registration is unaffected
end
+ deactivate Mediator
- RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken, confirmationEmailSent)
+ RegHandler-->>Mediator: RegistrationPayload(userAccountId, username, refreshToken, accessToken)
deactivate RegHandler
Mediator-->>API: RegistrationPayload
deactivate Mediator
diff --git a/docs/web/diagrams/auth/resend-confirmation.mmd b/docs/web/diagrams/auth/resend-confirmation.mmd
index cd985da3..514556fb 100644
--- a/docs/web/diagrams/auth/resend-confirmation.mmd
+++ b/docs/web/diagrams/auth/resend-confirmation.mmd
@@ -51,13 +51,20 @@ sequenceDiagram
TokenSvc-->>ResendHandler: confirmation token
deactivate TokenSvc
- ResendHandler->>Mediator: Send(SendResendConfirmationEmailCommand)
+ ResendHandler->>Mediator: Publish(ConfirmationEmailResendRequestedNotification)
activate Mediator
+ Note right of Mediator: Features.Emails has a direct ProjectReference on Features.Users and handles the notification via ConfirmationEmailResendRequestedNotificationHandler
Mediator->>EmailDispatcher: SendResendConfirmationEmailAsync(firstName, email, confirmationToken)
activate EmailDispatcher
EmailDispatcher->>Template: Render email (same Razor pipeline as registration)
EmailDispatcher->>SMTP: SendAsync(email, subject, body)
deactivate EmailDispatcher
+
+ alt Email sent successfully
+ Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler completes normally
+ else EmailDispatcher throws
+ Note right of Mediator: ConfirmationEmailResendRequestedNotificationHandler catches and logs the exception — the resend request is unaffected
+ end
deactivate Mediator
end
end
diff --git a/docs/web/diagrams/vertical-slice.md b/docs/web/diagrams/vertical-slice.md
index 69d4dacb..5d7467c3 100644
--- a/docs/web/diagrams/vertical-slice.md
+++ b/docs/web/diagrams/vertical-slice.md
@@ -16,13 +16,14 @@ doesn't wire up any slice; it applies schema directly.
## Project reference dependencies
-Solid arrows are compile-time project references. Dashed arrows are the two
-places slices talk to each other without a direct reference: the email MediatR
-contract routed through `Shared.Application`, and the one place that pattern is
-broken outright. Every DB-backed slice depends on the same three foundation
-projects (`Shared.Contracts`, `Domain.Entities`, `Database.Connection`) — drawn
-as one box instead of fifteen separate arrows; see the notes for what else each
-of those foundation projects pulls in.
+Solid arrows are compile-time project references — every slice-to-slice
+dependency is one now that the email MediatR contract has moved off
+`Shared.Application`: `Features.Users` on `Features.PhotoUpload` for avatar
+uploads, and `Features.Emails` on `Features.Users` for the registration and
+resend-confirmation notifications. Every DB-backed slice depends on the same
+three foundation projects (`Shared.Contracts`, `Domain.Entities`,
+`Database.Connection`) — drawn as one box instead of fifteen separate arrows;
+see the notes for what else each of those foundation projects pulls in.
```mermaid
--8<-- "web/diagrams/vertical-slice/project-references.mmd"
@@ -54,17 +55,20 @@ of those foundation projects pulls in.
- `Features.Beers` is scaffolded (project + references only, no source files
yet)
-- **The one exception to slice isolation**: `Features.Users` takes a direct
- project reference to `Features.PhotoUpload`
- - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
- - unlike the email flow, the command type lives in the target slice itself,
- not in `Shared.Application`
+- **The exceptions to slice isolation**:
+ - `Features.Users` takes a direct project reference to `Features.PhotoUpload`
+ - sends its `UploadPhotoCommand` via MediatR from `UploadAvatarHandler`
+ - the command type lives in the target slice itself, not in
+ `Shared.Application`
+ - `Features.Emails` takes a direct project reference to `Features.Users`
+ - handles `UserRegisteredNotification` (published from
+ `RegisterUserHandler`) and `ConfirmationEmailResendRequestedNotification`
+ (published from `ResendConfirmationEmailHandler`)
+ - both notification types live in `Features.Users`
+ (`Features.Auth.Notifications`), not in `Shared.Application`
- **Shared.Application**:
- `ValidationBehavior` (MediatR pipeline)
- - Cross-slice email commands (`SendRegistrationEmailCommand`,
- `SendResendConfirmationEmailCommand`)
- - MediatR command contract (not project reference)
- Every slice (including `Features.Emails`) references it for the pipeline
behavior alone
diff --git a/docs/web/diagrams/vertical-slice/project-references.mmd b/docs/web/diagrams/vertical-slice/project-references.mmd
index 7953bfaa..5ea0a881 100644
--- a/docs/web/diagrams/vertical-slice/project-references.mmd
+++ b/docs/web/diagrams/vertical-slice/project-references.mmd
@@ -31,8 +31,7 @@ EmailTemplates["Infrastructure.Email.Templates"]
end
UsersSlice == project ref, sends UploadPhotoCommand ==> PhotoSlice
-UsersSlice -. sends email commands .-> SharedApp["Shared.Application
(email command contract)"]
-EmailsSlice -. handles email commands .-> SharedApp
+EmailsSlice == project ref, handles UserRegisteredNotification & ConfirmationEmailResendRequestedNotification ==> UsersSlice
BrewerySlice --> FOUNDATION
LocationsSlice --> FOUNDATION
BeersSlice --> FOUNDATION
diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs
deleted file mode 100644
index 9b338b9a..00000000
--- a/web/backend/Features/Features.Emails.Tests/Commands/SendRegistrationEmailHandlerTests.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using Features.Emails.Commands.SendRegistrationEmail;
-using Features.Emails.Services;
-using Moq;
-using Shared.Application.Emails;
-
-namespace Features.Emails.Tests.Commands;
-
-public class SendRegistrationEmailHandlerTests
-{
- [Fact]
- public async Task Handle_DelegatesToEmailDispatcher()
- {
- Mock dispatcherMock = new();
- SendRegistrationEmailHandler handler = new(dispatcherMock.Object);
- SendRegistrationEmailCommand command = new("Aaron", "aaron@example.com", "token-123");
-
- await handler.Handle(command, CancellationToken.None);
-
- dispatcherMock.Verify(
- d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"),
- Times.Once
- );
- }
-}
diff --git a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs
deleted file mode 100644
index b8b02f65..00000000
--- a/web/backend/Features/Features.Emails.Tests/Commands/SendResendConfirmationEmailHandlerTests.cs
+++ /dev/null
@@ -1,24 +0,0 @@
-using Features.Emails.Commands.SendResendConfirmationEmail;
-using Features.Emails.Services;
-using Moq;
-using Shared.Application.Emails;
-
-namespace Features.Emails.Tests.Commands;
-
-public class SendResendConfirmationEmailHandlerTests
-{
- [Fact]
- public async Task Handle_DelegatesToEmailDispatcher()
- {
- Mock dispatcherMock = new();
- SendResendConfirmationEmailHandler handler = new(dispatcherMock.Object);
- SendResendConfirmationEmailCommand command = new("Aaron", "aaron@example.com", "token-456");
-
- await handler.Handle(command, CancellationToken.None);
-
- dispatcherMock.Verify(
- d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
- Times.Once
- );
- }
-}
diff --git a/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs
new file mode 100644
index 00000000..c0f7e60a
--- /dev/null
+++ b/web/backend/Features/Features.Emails.Tests/Notifications/ConfirmationEmailResendRequestedNotificationHandlerTests.cs
@@ -0,0 +1,63 @@
+using Features.Auth.Notifications;
+using Features.Emails.Notifications;
+using Features.Emails.Services;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace Features.Emails.Tests.Notifications;
+
+public class ConfirmationEmailResendRequestedNotificationHandlerTests
+{
+ [Fact]
+ public async Task Handle_DelegatesToEmailDispatcher()
+ {
+ Mock dispatcherMock = new();
+ ConfirmationEmailResendRequestedNotificationHandler handler = new(
+ dispatcherMock.Object,
+ Mock.Of>()
+ );
+ ConfirmationEmailResendRequestedNotification notification = new(
+ Guid.NewGuid(),
+ "Aaron",
+ "aaron@example.com",
+ "token-456"
+ );
+
+ await handler.Handle(notification, CancellationToken.None);
+
+ dispatcherMock.Verify(
+ d => d.SendResendConfirmationEmailAsync("Aaron", "aaron@example.com", "token-456"),
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task Handle_SwallowsDispatcherFailure()
+ {
+ Mock dispatcherMock = new();
+ dispatcherMock
+ .Setup(d =>
+ d.SendResendConfirmationEmailAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ThrowsAsync(new Exception("smtp down"));
+ ConfirmationEmailResendRequestedNotificationHandler handler = new(
+ dispatcherMock.Object,
+ Mock.Of>()
+ );
+ ConfirmationEmailResendRequestedNotification notification = new(
+ Guid.NewGuid(),
+ "Aaron",
+ "aaron@example.com",
+ "token-456"
+ );
+
+ Func act = async () => await handler.Handle(notification, CancellationToken.None);
+
+ await act.Should().NotThrowAsync();
+ }
+}
diff --git a/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs b/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs
new file mode 100644
index 00000000..a2bcf4eb
--- /dev/null
+++ b/web/backend/Features/Features.Emails.Tests/Notifications/UserRegisteredNotificationHandlerTests.cs
@@ -0,0 +1,63 @@
+using Features.Auth.Notifications;
+using Features.Emails.Notifications;
+using Features.Emails.Services;
+using FluentAssertions;
+using Microsoft.Extensions.Logging;
+using Moq;
+
+namespace Features.Emails.Tests.Notifications;
+
+public class UserRegisteredNotificationHandlerTests
+{
+ [Fact]
+ public async Task Handle_DelegatesToEmailDispatcher()
+ {
+ Mock dispatcherMock = new();
+ UserRegisteredNotificationHandler handler = new(
+ dispatcherMock.Object,
+ Mock.Of>()
+ );
+ UserRegisteredNotification notification = new(
+ Guid.NewGuid(),
+ "Aaron",
+ "aaron@example.com",
+ "token-123"
+ );
+
+ await handler.Handle(notification, CancellationToken.None);
+
+ dispatcherMock.Verify(
+ d => d.SendRegistrationEmailAsync("Aaron", "aaron@example.com", "token-123"),
+ Times.Once
+ );
+ }
+
+ [Fact]
+ public async Task Handle_SwallowsDispatcherFailure()
+ {
+ Mock dispatcherMock = new();
+ dispatcherMock
+ .Setup(d =>
+ d.SendRegistrationEmailAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny()
+ )
+ )
+ .ThrowsAsync(new Exception("smtp down"));
+ UserRegisteredNotificationHandler handler = new(
+ dispatcherMock.Object,
+ Mock.Of>()
+ );
+ UserRegisteredNotification notification = new(
+ Guid.NewGuid(),
+ "Aaron",
+ "aaron@example.com",
+ "token-123"
+ );
+
+ Func act = async () => await handler.Handle(notification, CancellationToken.None);
+
+ await act.Should().NotThrowAsync();
+ }
+}
diff --git a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs
deleted file mode 100644
index 6bd22d78..00000000
--- a/web/backend/Features/Features.Emails/Commands/SendRegistrationEmail/SendRegistrationEmailHandler.cs
+++ /dev/null
@@ -1,22 +0,0 @@
-using Features.Emails.Services;
-using MediatR;
-using Shared.Application.Emails;
-
-namespace Features.Emails.Commands.SendRegistrationEmail;
-
-///
-/// Handles , the cross-slice command sent by Features.Users
-/// after a new user registers.
-///
-public class SendRegistrationEmailHandler(IEmailDispatcher emailDispatcher)
- : IRequestHandler
-{
- public Task Handle(SendRegistrationEmailCommand request, CancellationToken cancellationToken)
- {
- return emailDispatcher.SendRegistrationEmailAsync(
- request.FirstName,
- request.Email,
- request.ConfirmationToken
- );
- }
-}
diff --git a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs
deleted file mode 100644
index c37551e0..00000000
--- a/web/backend/Features/Features.Emails/Commands/SendResendConfirmationEmail/SendResendConfirmationEmailHandler.cs
+++ /dev/null
@@ -1,25 +0,0 @@
-using Features.Emails.Services;
-using MediatR;
-using Shared.Application.Emails;
-
-namespace Features.Emails.Commands.SendResendConfirmationEmail;
-
-///
-/// Handles , the cross-slice command sent by Features.Users
-/// when a user requests a fresh confirmation link.
-///
-public class SendResendConfirmationEmailHandler(IEmailDispatcher emailDispatcher)
- : IRequestHandler
-{
- public Task Handle(
- SendResendConfirmationEmailCommand request,
- CancellationToken cancellationToken
- )
- {
- return emailDispatcher.SendResendConfirmationEmailAsync(
- request.FirstName,
- request.Email,
- request.ConfirmationToken
- );
- }
-}
diff --git a/web/backend/Features/Features.Emails/Features.Emails.csproj b/web/backend/Features/Features.Emails/Features.Emails.csproj
index 713ce39d..3f4e1875 100644
--- a/web/backend/Features/Features.Emails/Features.Emails.csproj
+++ b/web/backend/Features/Features.Emails/Features.Emails.csproj
@@ -18,5 +18,6 @@
+
diff --git a/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs b/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs
new file mode 100644
index 00000000..fd6b9422
--- /dev/null
+++ b/web/backend/Features/Features.Emails/Notifications/ConfirmationEmailResendRequestedNotificationHandler.cs
@@ -0,0 +1,44 @@
+using Features.Auth.Notifications;
+using Features.Emails.Services;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace Features.Emails.Notifications;
+
+///
+/// Handles , the cross-slice notification
+/// published by Features.Users when a user requests a fresh confirmation link, by sending the resend
+/// confirmation email.
+///
+public class ConfirmationEmailResendRequestedNotificationHandler(
+ IEmailDispatcher emailDispatcher,
+ ILogger logger
+) : INotificationHandler
+{
+ ///
+ /// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server
+ /// never fails the resend request that already succeeded.
+ ///
+ public async Task Handle(
+ ConfirmationEmailResendRequestedNotification notification,
+ CancellationToken cancellationToken
+ )
+ {
+ try
+ {
+ await emailDispatcher.SendResendConfirmationEmailAsync(
+ notification.FirstName,
+ notification.Email,
+ notification.ConfirmationToken
+ );
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Failed to send resend confirmation email to user {UserId}",
+ notification.UserId
+ );
+ }
+ }
+}
diff --git a/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs b/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs
new file mode 100644
index 00000000..41e1d893
--- /dev/null
+++ b/web/backend/Features/Features.Emails/Notifications/UserRegisteredNotificationHandler.cs
@@ -0,0 +1,40 @@
+using Features.Auth.Notifications;
+using Features.Emails.Services;
+using MediatR;
+using Microsoft.Extensions.Logging;
+
+namespace Features.Emails.Notifications;
+
+///
+/// Handles , the cross-slice notification published by
+/// Features.Users after a new user registers, by sending the registration confirmation email.
+///
+public class UserRegisteredNotificationHandler(
+ IEmailDispatcher emailDispatcher,
+ ILogger logger
+) : INotificationHandler
+{
+ ///
+ /// Delivery failures are logged and swallowed rather than rethrown, so that a broken mail server
+ /// never fails the registration that already succeeded.
+ ///
+ public async Task Handle(UserRegisteredNotification notification, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await emailDispatcher.SendRegistrationEmailAsync(
+ notification.FirstName,
+ notification.Email,
+ notification.ConfirmationToken
+ );
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(
+ ex,
+ "Failed to send registration confirmation email to user {UserId}",
+ notification.UserId
+ );
+ }
+ }
+}
diff --git a/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs
index d2ee91bf..68543336 100644
--- a/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs
+++ b/web/backend/Features/Features.Users.Tests/Commands/RegisterUserHandlerTests.cs
@@ -2,13 +2,13 @@ using Domain.Exceptions;
using Features.Auth.Commands.Authentication.RegisterUser;
using Features.Auth.Dtos;
using Features.Auth.Identity;
+using Features.Auth.Notifications;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using FluentAssertions;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq;
-using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
@@ -86,10 +86,18 @@ public class RegisterUserHandlerTests
result.Username.Should().Be(command.Username);
result.AccessToken.Should().Be("access-token");
result.RefreshToken.Should().Be("refresh-token");
- result.ConfirmationEmailSent.Should().BeTrue();
_mediatorMock.Verify(
- x => x.Send(It.IsAny(), It.IsAny()),
+ x =>
+ x.Publish(
+ It.Is(n =>
+ n.UserId == expectedUserId
+ && n.FirstName == command.FirstName
+ && n.Email == command.Email
+ && n.ConfirmationToken == "confirmation-token"
+ ),
+ It.IsAny()
+ ),
Times.Once
);
}
@@ -177,31 +185,4 @@ public class RegisterUserHandlerTests
);
}
- [Fact]
- public async Task Handle_SwallowsEmailFailure_AndReportsEmailNotSent()
- {
- RegisterUserCommand command = ValidCommand();
-
- _userManagerMock
- .Setup(x => x.CreateAsync(It.IsAny(), command.Password))
- .Callback((u, _) => u.Id = Guid.NewGuid())
- .ReturnsAsync(IdentityResult.Success);
-
- _tokenServiceMock
- .Setup(x => x.GenerateAccessToken(It.IsAny(), It.IsAny()))
- .Returns("access-token");
- _tokenServiceMock
- .Setup(x => x.GenerateRefreshToken(It.IsAny(), It.IsAny()))
- .Returns("refresh-token");
- _mediatorMock
- .Setup(x =>
- x.Send(It.IsAny(), It.IsAny())
- )
- .ThrowsAsync(new Exception("smtp down"));
-
- RegistrationPayload result = await _handler.Handle(command, CancellationToken.None);
-
- result.ConfirmationEmailSent.Should().BeFalse();
- result.AccessToken.Should().Be("access-token");
- }
}
diff --git a/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs
index 974b94d9..6f460ab6 100644
--- a/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs
+++ b/web/backend/Features/Features.Users.Tests/Commands/ResendConfirmationEmailHandlerTests.cs
@@ -1,11 +1,11 @@
using Features.Auth.Commands.Authentication.ResendConfirmationEmail;
using Features.Auth.Identity;
+using Features.Auth.Notifications;
using Features.Auth.Services;
using Features.Auth.Tests.TestSupport;
using MediatR;
using Microsoft.AspNetCore.Identity;
using Moq;
-using Shared.Application.Emails;
namespace Features.Auth.Tests.Commands;
@@ -47,11 +47,12 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify(
x =>
- x.Send(
- It.Is(c =>
- c.FirstName == "Aaron"
- && c.Email == "aaron@example.com"
- && c.ConfirmationToken == "fresh-token"
+ x.Publish(
+ It.Is(n =>
+ n.UserId == userId
+ && n.FirstName == "Aaron"
+ && n.Email == "aaron@example.com"
+ && n.ConfirmationToken == "fresh-token"
),
It.IsAny()
),
@@ -71,8 +72,8 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify(
x =>
- x.Send(
- It.IsAny(),
+ x.Publish(
+ It.IsAny(),
It.IsAny()
),
Times.Never
@@ -92,8 +93,8 @@ public class ResendConfirmationEmailHandlerTests
_mediatorMock.Verify(
x =>
- x.Send(
- It.IsAny(),
+ x.Publish(
+ It.IsAny(),
It.IsAny()
),
Times.Never
diff --git a/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs
index 8d22714e..077b2223 100644
--- a/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs
+++ b/web/backend/Features/Features.Users/Commands/Authentication/RegisterUser/RegisterUserHandler.cs
@@ -3,17 +3,17 @@ using Domain.Exceptions;
using Features.Auth.Commands.Profile.CreateUserProfile;
using Features.Auth.Dtos;
using Features.Auth.Identity;
+using Features.Auth.Notifications;
using Features.Auth.Services;
using MediatR;
using Microsoft.AspNetCore.Identity;
-using Shared.Application.Emails;
namespace Features.Auth.Commands.Authentication.RegisterUser;
///
/// Handles : creates the user via
/// (which validates uniqueness and hashes the password), issues access/refresh/confirmation tokens, and
-/// attempts to send the registration confirmation email via Features.Emails.
+/// publishes for Features.Emails to send the confirmation email.
///
public class RegisterUserHandler(
UserManager userManager,
@@ -60,31 +60,19 @@ public class RegisterUserHandler(
user.Id,
user.UserName,
string.Empty,
- string.Empty,
- false
+ string.Empty
);
- bool emailSent = false;
- try
- {
- await mediator.Send(
- new SendRegistrationEmailCommand(user.FirstName, user.Email, confirmationToken),
- cancellationToken
- );
- emailSent = true;
- }
- catch (Exception ex)
- {
- await Console.Error.WriteLineAsync(ex.Message);
- Console.WriteLine("Could not send email.");
- }
+ await mediator.Publish(
+ new UserRegisteredNotification(user.Id, request.FirstName, user.Email, confirmationToken),
+ cancellationToken
+ );
return new RegistrationPayload(
user.Id,
user.UserName,
refreshToken,
- accessToken,
- emailSent
+ accessToken
);
}
}
diff --git a/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs
index eabc4a65..69edb439 100644
--- a/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs
+++ b/web/backend/Features/Features.Users/Commands/Authentication/ResendConfirmationEmail/ResendConfirmationEmailHandler.cs
@@ -1,14 +1,14 @@
using Features.Auth.Identity;
+using Features.Auth.Notifications;
using Features.Auth.Services;
using MediatR;
using Microsoft.AspNetCore.Identity;
-using Shared.Application.Emails;
namespace Features.Auth.Commands.Authentication.ResendConfirmationEmail;
///
/// Handles by generating a fresh confirmation token and
-/// sending it via Features.Emails.
+/// publishing for Features.Emails to send it.
///
///
/// Returns silently without sending an email if the user does not exist (to prevent user enumeration)
@@ -33,8 +33,13 @@ public class ResendConfirmationEmailHandler(
return; // Already confirmed, no-op
string confirmationToken = tokenService.GenerateConfirmationToken(user.Id, user.UserName);
- await mediator.Send(
- new SendResendConfirmationEmailCommand(user.FirstName, user.Email, confirmationToken),
+ await mediator.Publish(
+ new ConfirmationEmailResendRequestedNotification(
+ user.Id,
+ user.FirstName,
+ user.Email,
+ confirmationToken
+ ),
cancellationToken
);
}
diff --git a/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs b/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs
index f6c9e9ba..d63d3eaf 100644
--- a/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs
+++ b/web/backend/Features/Features.Users/Dtos/AuthPayloads.cs
@@ -13,9 +13,7 @@ public record RegistrationPayload(
Guid UserAccountId,
string Username,
string RefreshToken,
- string AccessToken,
- bool ConfirmationEmailSent
-);
+ string AccessToken);
/// Payload returned to the client after a user account's email has been confirmed.
public record ConfirmationPayload(Guid UserAccountId, DateTime ConfirmedDate);
diff --git a/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs b/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs
new file mode 100644
index 00000000..ef731dd1
--- /dev/null
+++ b/web/backend/Features/Features.Users/Notifications/ConfirmationEmailResendRequestedNotification.cs
@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace Features.Auth.Notifications;
+
+///
+/// Published after a user requests a fresh confirmation link. Handled by Features.Emails to send the
+/// resend confirmation email.
+///
+public record ConfirmationEmailResendRequestedNotification(
+ Guid UserId,
+ string FirstName,
+ string Email,
+ string ConfirmationToken
+) : INotification;
diff --git a/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs b/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs
new file mode 100644
index 00000000..fb54185d
--- /dev/null
+++ b/web/backend/Features/Features.Users/Notifications/UserRegisteredNotification.cs
@@ -0,0 +1,14 @@
+using MediatR;
+
+namespace Features.Auth.Notifications;
+
+///
+/// Published after a new user registers. Handled by Features.Emails to send the registration
+/// confirmation email.
+///
+public record UserRegisteredNotification(
+ Guid UserId,
+ string FirstName,
+ string Email,
+ string ConfirmationToken
+) : INotification;
diff --git a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs
deleted file mode 100644
index 80e1133e..00000000
--- a/web/backend/Shared/Shared.Application/Emails/SendRegistrationEmailCommand.cs
+++ /dev/null
@@ -1,10 +0,0 @@
-using MediatR;
-
-namespace Shared.Application.Emails;
-
-///
-/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a
-/// registration confirmation email, without either slice taking a project reference on the other.
-///
-public record SendRegistrationEmailCommand(string FirstName, string Email, string ConfirmationToken)
- : IRequest;
diff --git a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs b/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs
deleted file mode 100644
index af2fa75b..00000000
--- a/web/backend/Shared/Shared.Application/Emails/SendResendConfirmationEmailCommand.cs
+++ /dev/null
@@ -1,13 +0,0 @@
-using MediatR;
-
-namespace Shared.Application.Emails;
-
-///
-/// Cross-slice command sent by Features.Users and handled by Features.Emails to dispatch a
-/// fresh confirmation-link email, without either slice taking a project reference on the other.
-///
-public record SendResendConfirmationEmailCommand(
- string FirstName,
- string Email,
- string ConfirmationToken
-) : IRequest;
diff --git a/web/frontend/app/features/auth/auth.server.ts b/web/frontend/app/features/auth/auth.server.ts
index e3835974..29abc33f 100644
--- a/web/frontend/app/features/auth/auth.server.ts
+++ b/web/frontend/app/features/auth/auth.server.ts
@@ -21,9 +21,7 @@ interface LoginPayload {
accessToken: string;
}
-interface RegistrationPayload extends LoginPayload {
- confirmationEmailSent: boolean;
-}
+type RegistrationPayload = LoginPayload;
interface UpdateUsernamePayload {
userAccountId: string;