Compare commits

...

19 Commits

Author SHA1 Message Date
2e69fac250 Add support for email attachments in SendEmailCommand
Enhanced the email-sending workflow to support attachments:
- Updated `SendEmailCommand` with an `Attachments` property.
- Added `WithAttachments` method to handle attachment initialization.
- Modified `EmailsController` to accept file uploads via `IFormFileCollection`.
- Implemented `BuildAttachmentsAsync` to process uploaded files.
- Updated `EmailMappingProfile` to map `Attachments` to `EmailContext`.
- Adjusted `SendEmail` endpoint to consume `multipart/form-data`.
- Enhanced `SendEmail` response to include the queued event ID.
- Updated project file to include Swagger infrastructure folder.

These changes enable handling of email attachments and improve API functionality.
2026-08-05 17:14:44 +02:00
890c32f1c8 Add support for email attachments in messaging service
Introduced the `EmailAttachmentContext` class to represent email
attachments, with properties for file name, content, content type,
inline display behavior, and content ID. Used conditional compilation
to support both .NET Framework and .NET versions.

Updated the `EmailContext` class to include an `Attachments`
property, enabling emails to include attachments as byte arrays or
file paths.

Enhanced the `LimilabsEmailService` class to handle attachments:
- Added the `AddAttachments` method to process inline and regular
  attachments.
- Integrated attachment handling into the email-building process.
2026-08-05 16:00:55 +02:00
fa9b4973b9 Introduce RabbitMQ consumer pool for parallel processing
Enhanced RabbitMQ email processing by introducing a `SendingEmailConsumerPool` to enable the competing consumers pattern. Each consumer operates on its own channel, improving scalability and thread safety.

- Added `SendingEmailConsumerPool` to manage multiple consumers.
- Updated `DependencyInjection` to register the consumer pool.
- Refactored `SendingEmailConsumer` for better logging and error handling.
- Updated `AsyncInitWorker` to initialize the consumer pool.
- Added `ConsumerConcurrency` to RabbitMQ configuration.
- Improved error handling in `LimilabsEmailService` with detailed SMTP error messages.
2026-08-05 15:34:33 +02:00
bd31bfe528 Add Serilog configuration to appsettings.Development.json
Introduced a new "Serilog" configuration section to enhance
logging control in the development environment. Set the
default logging level to "Debug" and added overrides for
specific namespaces ("Microsoft", "Microsoft.AspNetCore",
"Microsoft.EntityFrameworkCore", and "System") to "Warning".
2026-08-05 15:33:56 +02:00
dbf78653b4 Refactor project structure and improve security
- Updated `.gitignore` to exclude `FodyWeavers.xsd`.
- Added or modified `/EnvelopeGenerator.Server/tekh_softHSM_test.md`.
- Added or modified `/EnvelopeGenerator.Server/publish-output`.
- Added or modified `/legacy/App`.
- Moved `appsettings.Secrets.json` to `/src/presentation/`.
- Removed sensitive configuration data from `appsettings.Secrets.json`, including RabbitMQ credentials and email account settings.
- Improved security by removing hardcoded secrets and restructuring configuration files.
2026-08-05 15:33:38 +02:00
28f7a3607a Update EmailContext mapping to ignore Sender property
The `EmailMappingProfile` class was updated to modify the mapping
configuration between `SendEmailCommand` and `EmailContext`.
A `.ForMember` configuration was added to explicitly ignore the
`Sender` property in the destination (`EmailContext`) during the
mapping process. This ensures that `Sender` is not mapped from
the source object and must be set separately.
2026-08-05 14:34:20 +02:00
42b30d4ac4 Refactor SendEmailAsync to use EmailContext object
Simplified the `SendEmailAsync` method in the `IEmailService`
interface to accept a single `EmailContext` object instead of
multiple parameters. Updated the `SendingEmailConsumer` and
`LimilabsEmailService` classes to align with this change.

In `LimilabsEmailService`, refactored email construction logic
to use properties from the `EmailContext` object, including
`Sender`, `Recipients`, `Subject`, `Body`, and `IsHtml`.
Updated `ConnectAndAuthenticateSmtpAsync` to use the `Sender`
property from `EmailContext`.

These changes improve code readability, reduce parameter
complexity, and ensure consistency across the email service
implementation.
2026-08-05 14:14:27 +02:00
3cd8841e80 Refactor Email to EmailContext across codebase
Replaced the `Email` record with the new `EmailContext` record to introduce additional context and functionality in email handling. Updated property definitions to distinguish between .NET Framework (`set`) and other frameworks (`init`).

Modified `SendingEmailEvent` to use `EmailContext` for the `Mail` property. Updated mappings in `EmailMappingProfile` to map `SendEmailCommand` to `EmailContext`. Adjusted `SendEmailCommandHandler` to use `EmailContext` when mapping requests.

Refactored `EmailSender` to use `EmailContext` in its `Send` method, including updates to method signatures and documentation. Updated all related test classes (`EmailSenderTests`, `EmailSenderUrlOverloadTests`, `SendingEmailPublisherTests`) to validate the behavior of `EmailContext`, ensuring consistency and thorough testing of the transition.

These changes ensure compatibility across frameworks and improve the maintainability of the email handling process.
2026-08-05 14:06:41 +02:00
c6e67c0f99 Refactor email handling for improved structure
Refactored `Email` and `SendingEmailEvent` to use `record` types, consolidating email-related data into the `Email` class. Updated `SendEmailCommand` to return a `Guid` and simplified mapping logic in `EmailMappingProfile`. Adjusted `SendEmailCommandHandler` to construct `SendingEmailEvent` manually.

Updated `SendingEmailConsumer`, `EmailsController`, and `EmailSender` to reflect the new structure. Removed the old `Email` implementation. Improved logging to reference the `Mail` property.

Revised tests to align with the new structure, ensuring immutability and better separation of concerns.
2026-08-05 13:59:54 +02:00
66afdefbd8 refactor: rename OutgoingEmail files to SendingEmail 2026-08-05 13:29:53 +02:00
58ad50b96b Refactor: Rename OutgoingEmail to SendingEmail
This commit renames and refactors all instances of `OutgoingEmail` to `SendingEmail` across the codebase to improve terminology consistency and align with domain language.

- Renamed classes, interfaces, and records (e.g., `OutgoingEmailPublisher` → `SendingEmailPublisher`, `OutgoingEmailEvent` → `SendingEmailEvent`).
- Updated method signatures, parameters, and return types to use `SendingEmail`.
- Adjusted dependency injection registrations to reflect the new naming.
- Updated mappings in `EmailMappingProfile` to map `SendEmailCommand` to `SendingEmailEvent`.
- Refactored `SendEmailCommand` and its handler to work with `SendingEmailEvent`.
- Updated `EmailsController` to use `SendingEmailEvent` in the `SendEmail` action.
- Refactored integration tests to test `SendingEmailPublisher` and updated test data accordingly.
- Updated log messages, error handling, and comments to reflect the new terminology.
- Revised documentation and utility methods to use `SendingEmailEvent`.

This refactor ensures consistency, improves readability, and reduces ambiguity in the codebase.
2026-08-05 13:26:21 +02:00
e550db8789 Update project references with PrivateAssets
Updated `<ProjectReference>` entries for `DigitalData.MessagingService.Abstraction` and `DigitalData.MessagingService.Publisher` to include `<PrivateAssets>all</PrivateAssets>`, ensuring they are marked as private assets.

Added a new `<ProjectReference>` for `DigitalData.MessagingService.RabbitMQ` with `<PrivateAssets>all</PrivateAssets>`, making it a private dependency.
2026-08-05 13:23:18 +02:00
601fd9be5f Refactor: Consolidate Publisher.Abstraction into Abstraction
The `DigitalData.MessagingService.Publisher.Abstraction` project has been removed, and its functionality has been merged into a new project named `DigitalData.MessagingService.Abstraction`.

- Updated namespaces from `Publisher.Abstraction` to `Abstraction` across all relevant files, including DTOs, interfaces, and classes.
- Modified the solution file to remove `Publisher.Abstraction` and add `Abstraction`, updating solution configurations and nested project mappings.
- Replaced project references to `Publisher.Abstraction` with `Abstraction` in all affected project files.
- Updated tests and integration tests to reflect the namespace and project changes.
- Refactored application-level files such as `EmailMappingProfile` and `DependencyInjection.cs` to use the new namespace.

This refactor simplifies the project structure and ensures consistency across the solution.
2026-08-05 13:16:03 +02:00
0d9d15032f Refactor email handling for multiple recipients
Refactored the `Email` and `OutgoingEmailEvent` classes to replace the `Recipient` property with a `Recipients` collection, enabling support for multiple recipients. Updated all related test cases, including `EmailSenderTests`, `EmailSenderUrlOverloadTests`, and `OutgoingEmailPublisherTests`, to reflect this change.

Moved the `EmailAccountDto` class and its references from the `DigitalData.MessagingService.Application.Common.Dtos` namespace to the `DigitalData.MessagingService.Publisher.Abstraction` namespace for better code organization. Updated `using` directives across affected files.

Removed unused `using` directives and updated the `Email` class's `ToEvent` method to map the new `Recipients` property. Adjusted test assertions to validate collections instead of single recipient strings.
2026-08-05 13:11:19 +02:00
e47333cd1d Support multiple email recipients in email-sending flow
Updated the `IEmailService` interface and related components to
support multiple recipients in the `SendEmailAsync` method.

- Replaced `Recipient` with `Recipients` in `SendEmailCommand`,
  `OutgoingEmailEvent`, and `EmailsController`.
- Updated `SendEmailCommandValidator` to validate a collection
  of recipients, ensuring at least one valid email address.
- Modified `LimilabsEmailService` to handle multiple recipients
  by iterating over the collection and adding each to the email.
- Adjusted `OutgoingEmailConsumer` to process and log multiple
  recipients.
- Updated logging and response structures to reflect the changes.

These changes enable the system to handle emails with multiple
recipients while maintaining proper validation and logging.
2026-08-05 13:02:57 +02:00
740bb8c313 Refactor email account handling for dynamic resolution
Reintroduced `EmailAccountDto` with conditional compilation to support both .NET and non-.NET environments. Updated `IEmailService` to accept `EmailAccountDto` as the sender, replacing reliance on pre-configured SMTP credentials.

Added `GetSenderQuery` and its handler to dynamically resolve email accounts based on `Id` or `Username`. Introduced `GetSenderQueryValidator` for validation, ensuring proper usage of the query.

Modified `SendEmailCommand` to include sender resolution via MediatR. Updated `OutgoingEmailEvent` to include sender information and adjusted `OutgoingEmailConsumer` and `LimilabsEmailService` to use the dynamically resolved sender.

Updated `EmailMappingProfile` to ignore the `Sender` property during mapping. Replaced `Name` with `Id` in `appsettings.Secrets.json` for email accounts. Removed the old `EmailAccountDto` folder and performed general cleanup and restructuring.
2026-08-05 12:32:08 +02:00
7fa3c4888a Refactor repository namespaces for better organization
Updated the namespace in `IRepository.cs` and `Repository.cs`
from `DigitalData.MessagingService.Application.Common.Interfaces`
to `DigitalData.MessagingService.Application.Common.Interfaces.Repositories`.
This change improves code organization by grouping repository-related
interfaces and classes under a dedicated `Repositories` namespace.
Updated `using` directives accordingly to reflect the new structure.
2026-08-05 11:06:42 +02:00
e0e399f5ed Refactor LimilabsEmailService for async operations
Modernized LimilabsEmailService by replacing synchronous SMTP
operations with asynchronous counterparts (e.g., SendMessageAsync,
CloseAsync). Introduced a new SmtpExtensions class with a
CloseSafelyAsync method for safe disconnection. Removed the
DisconnectSafely method and replaced its usage with the new
extension method. Improved exception handling and removed
redundant Task.CompletedTask calls. These changes enhance
code readability, ensure safe resource cleanup, and align
with asynchronous programming practices.
2026-08-05 11:03:59 +02:00
e53fabcda2 Refactor email account configuration for multi-account support
Refactored `EmailAccountDto` to represent a single account with
immutable properties and added an `Id` field. Introduced
`EmailAccountsOptions` to manage multiple accounts and bound it
to the `EmailAccounts` configuration section.

Updated `DependencyInjection` to register `EmailAccountsOptions`
and removed the old single-account binding. Refactored
`LimilabsEmailService` to use `EmailAccountsOptions` and select
the appropriate account dynamically.

Replaced the `EmailAccount` section in `appsettings.Secrets.json`
with a new `EmailAccounts` section supporting multiple accounts.
Added a package reference for `Microsoft.Extensions.Options.
ConfigurationExtensions` to support the options pattern.
2026-08-05 10:46:43 +02:00
45 changed files with 671 additions and 336 deletions

2
.gitignore vendored
View File

@@ -371,3 +371,5 @@ FodyWeavers.xsd
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
/legacy/App
/src/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json

View File

@@ -330,7 +330,7 @@ return Accepted(); // HTTP 202 - command queued for processing
arguments: null);
}
public async Task EnqueueAsync(OutgoingEmail email, CancellationToken cancellationToken)
public async Task EnqueueAsync(SendingEmail email, CancellationToken cancellationToken)
{
var json = JsonSerializer.Serialize(email);
var body = Encoding.UTF8.GetBytes(json);
@@ -347,7 +347,7 @@ return Accepted(); // HTTP 202 - command queued for processing
await Task.CompletedTask;
}
public async Task<OutgoingEmail?> DequeueAsync(CancellationToken cancellationToken)
public async Task<SendingEmail?> DequeueAsync(CancellationToken cancellationToken)
{
var result = _channel.BasicGet(QueueName, autoAck: false);
@@ -355,7 +355,7 @@ return Accepted(); // HTTP 202 - command queued for processing
return null;
var json = Encoding.UTF8.GetString(result.Body.ToArray());
var email = JsonSerializer.Deserialize<OutgoingEmail>(json);
var email = JsonSerializer.Deserialize<SendingEmail>(json);
_channel.BasicAck(result.DeliveryTag, false);

View File

@@ -27,8 +27,6 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher.Abstraction", "src\core\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj", "{8A44EE33-02AA-8B3F-60F7-91BA483740A9}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
@@ -39,6 +37,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client", "src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj", "{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Abstraction", "src\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj", "{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -57,10 +57,6 @@ Global
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.Build.0 = Release|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A44EE33-02AA-8B3F-60F7-91BA483740A9}.Release|Any CPU.Build.0 = Release|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -81,6 +77,10 @@ Global
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.Build.0 = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.ActiveCfg = Release|Any CPU
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Release|Any CPU.Build.0 = Release|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -92,12 +92,12 @@ Global
{71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
{7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{8E44FA5B-43DD-E273-C682-FC382A854A6D} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{8A44EE33-02AA-8B3F-60F7-91BA483740A9} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
{6D5E14FC-E4E1-32E3-4F3F-19DE96233128} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}

View File

@@ -0,0 +1,38 @@
namespace DigitalData.MessagingService.Abstraction;
/// <summary>
/// DTO for a single email account configuration.
/// </summary>
public class EmailAccountDto
{
/// <summary>
/// Logical name to identify this account (e.g. "default", "support").
/// </summary>
public int Id { get; set; }
#if NET
public required string Username { get; set; }
#else
public string Username { get; set; } = null!;
#endif
#if NET
public required string Password { get; set; }
#else
public string Password { get; set; } = null!;
#endif
public bool PasswordEncrypted { get; set; } = false;
#if NET
public required string SmtpServer { get; set; }
#else
public string SmtpServer { get; set; } = null!;
#endif
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
}

View File

@@ -0,0 +1,45 @@
namespace DigitalData.MessagingService.Abstraction;
/// <summary>
/// Represents a single email attachment.
/// </summary>
public sealed class EmailAttachmentContext
{
/// <summary>
/// Display name of the attachment (e.g. "invoice.pdf").
/// </summary>
#if NETFRAMEWORK
public string FileName { get; set; } = null!;
#else
public required string FileName { get; init; }
#endif
/// <summary>
/// Raw content of the attachment.
/// </summary>
#if NETFRAMEWORK
public byte[] Content { get; set; } = null!;
#else
public required byte[] Content { get; init; }
#endif
/// <summary>
/// MIME content-type (e.g. "application/pdf", "image/png").
/// Defaults to "application/octet-stream" when not specified.
/// </summary>
public string ContentType { get; set; } = "application/octet-stream";
/// <summary>
/// When <see langword="true"/> the attachment is embedded inline and displayed
/// directly inside the email body via a CID reference (e.g. &lt;img src="cid:logo"&gt;).
/// When <see langword="false"/> (default) it appears as a regular downloadable attachment.
/// </summary>
public bool IsInline { get; set; } = false;
/// <summary>
/// Content-ID used when <see cref="IsInline"/> is <see langword="true"/>.
/// Reference it in HTML body as <c>cid:{ContentId}</c>.
/// Auto-generated from <see cref="FileName"/> when left empty.
/// </summary>
public string? ContentId { get; set; }
}

View File

@@ -0,0 +1,55 @@
namespace DigitalData.MessagingService.Abstraction;
public record EmailContext
{
#if NETFRAMEWORK
public EmailAccountDto Sender { get; set; } = null!;
#else
public required EmailAccountDto Sender { get; init; }
#endif
/// <summary>
/// Recipient email address
/// </summary>
#if NETFRAMEWORK
public IEnumerable<string> Recipients { get; set; } = null!;
#else
public required IEnumerable<string> Recipients { get; init; }
#endif
/// <summary>
/// Email subject
/// </summary>
#if NETFRAMEWORK
public string Subject { get; set; } = null!;
#else
public required string Subject { get; init; }
#endif
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
#if NETFRAMEWORK
public string Body { get; set; } = null!;
#else
public required string Body { get; init; }
#endif
/// <summary>
/// Is HTML email (default: true)
/// </summary>
#if NETFRAMEWORK
public bool IsHtml { get; set; } = true;
#else
public bool IsHtml { get; init; } = true;
#endif
/// <summary>
/// Optional list of attachments to include with the email.
/// Each entry may carry its content as a byte array (<see cref="EmailAttachmentContext.Content"/>)
/// or reference a file on disk via <see cref="EmailAttachmentContext.FilePath"/>.
/// </summary>
public IEnumerable<EmailAttachmentContext> Attachments { get; set; } = [];
}

View File

@@ -0,0 +1,11 @@
namespace DigitalData.MessagingService.Abstraction;
/// <summary>
/// Email queue interface for outgoing emails.
/// </summary>
public interface ISendingEmailPublisher
{
Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default);
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,6 @@
namespace DigitalData.MessagingService.Publisher.Abstraction;
namespace DigitalData.MessagingService.Abstraction;
public record OutgoingEmailCreateDto
public record SendingEmailCreateDto
{
/// <summary>
/// Recipient email address

View File

@@ -0,0 +1,22 @@
namespace DigitalData.MessagingService.Abstraction;
public record SendingEmailEvent
{
#if NETFRAMEWORK
public Guid Id { get; set; }
#else
public required Guid Id { get; init; }
#endif
#if NETFRAMEWORK
public EmailContext Mail { get; set; } = null!;
#else
public required EmailContext Mail { get; init; }
#endif
#if NETFRAMEWORK
public DateTime QueuedAt { get; set; }
#else
public required DateTime QueuedAt { get; init; }
#endif
}

View File

@@ -1,21 +0,0 @@
namespace DigitalData.MessagingService.Application.Common.Dtos;
/// <summary>
/// DTO for EmailAccount query results.
/// </summary>
public class EmailAccountDto
{
public string Username { get; set; } = null!;
public string Password { get; set; } = null!;
public bool PasswordEncrypted { get; set; } = false;
public string SmtpServer { get; set; } = null!;
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public bool UseOAuth2 { get; set; }
}

View File

@@ -1,3 +1,5 @@
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
/// <summary>
@@ -12,5 +14,5 @@ public interface IEmailService
/// Sends an email using the configured SMTP account.
/// SMTP credentials are configured in appsettings.json (EmailAccount section).
/// </summary>
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default);
Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default);
}

View File

@@ -1,6 +1,6 @@
using System.Linq.Expressions;
namespace DigitalData.MessagingService.Application.Common.Interfaces;
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
/// <summary>
/// Generic repository interface for CRUD operations.

View File

@@ -1,6 +1,6 @@
using AutoMapper;
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Mappings;
@@ -11,9 +11,10 @@ public class EmailMappingProfile : Profile
{
public EmailMappingProfile()
{
// SendEmailCommand -> OutgoingEmailEvent
CreateMap<SendEmailCommand, OutgoingEmailEvent>()
.ForMember(dest => dest.Id, opt => opt.MapFrom(_ => Guid.NewGuid()))
.ForMember(dest => dest.QueuedAt, opt => opt.MapFrom(_ => DateTime.Now));
// SendEmailCommand -> Email
// Sender is resolved via MediatR in the handler and set separately after mapping.
CreateMap<SendEmailCommand, EmailContext>()
.ForMember(dest => dest.Sender, opt => opt.Ignore())
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
}
}

View File

@@ -0,0 +1,17 @@
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Application.Common.Options;
/// <summary>
/// Wrapper options class that holds a list of <see cref="EmailAccountDto"/> entries
/// bound from the <c>EmailAccounts</c> configuration section.
/// </summary>
public class EmailAccountsOptions
{
public const string SectionName = "EmailAccounts";
/// <summary>
/// The list of configured email accounts.
/// </summary>
public required IEnumerable<EmailAccountDto> Accounts { get; init; } = [];
}

View File

@@ -1,7 +1,9 @@
using System.Reflection;
using DigitalData.MessagingService.Application.Common.Options;
using FluentValidation;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using System.Reflection;
namespace DigitalData.MessagingService.Application;
@@ -35,6 +37,9 @@ public static class DependencyInjection
// FluentValidation - Register all validators
services.AddValidatorsFromAssembly(assembly);
// Register EmailAccounts configuration (IOptions<EmailAccountsOptions>)
services.Configure<EmailAccountsOptions>(configuration.GetSection(EmailAccountsOptions.SectionName));
return services;
}
}

View File

@@ -8,7 +8,7 @@
<ItemGroup>
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
</ItemGroup>
<ItemGroup>
@@ -17,10 +17,7 @@
<PackageReference Include="MediatR" Version="14.2.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
</ItemGroup>
<ItemGroup>
<Folder Include="Common\Dtos\" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,39 @@
using DigitalData.MessagingService.Application.Common.Options;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Application.EmailAccount.Queries;
public record GetSenderQuery : IRequest<EmailAccountDto?>
{
public int? Id { get; init; }
public string? Username { get; init; }
}
/// <summary>
///
/// </summary>
/// <param name="Options"></param>
/// <param name="Logger"></param>
public class GetSenderQueryHandler(IOptions<EmailAccountsOptions> Options, ILogger<GetSenderQueryHandler> Logger) : IRequestHandler<GetSenderQuery, EmailAccountDto?>
{
public Task<EmailAccountDto?> Handle(GetSenderQuery request, CancellationToken cancellationToken)
{
var accounts = request.Id is not null
? Options.Value.Accounts.Where(a => a.Id == request.Id)
: Options.Value.Accounts.Where(a => a.Username == request.Username);
if(accounts.Count() > 1)
{
Logger.LogWarning(
"Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.",
request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}"
);
}
return Task.FromResult(accounts.FirstOrDefault());
}
}

View File

@@ -0,0 +1,27 @@
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailAccount.Validators;
/// <summary>
/// Validator for <see cref="GetSenderQuery"/>.
/// Either <see cref="GetSenderQuery.Id"/> or <see cref="GetSenderQuery.Username"/> must be provided, but not both.
/// </summary>
public class GetSenderQueryValidator : AbstractValidator<GetSenderQuery>
{
public GetSenderQueryValidator()
{
RuleFor(x => x)
.Must(x => (x.Id is not null) ^ (x.Username is not null))
.WithMessage("Either Id or Username must be provided, but not both.");
When(x => x.Username is not null, () =>
{
RuleFor(x => x.Username)
.NotEmpty()
.WithMessage("Username must not be empty.")
.MaximumLength(200)
.WithMessage("Username must not exceed 200 characters.");
});
}
}

View File

@@ -1,18 +1,23 @@
using AutoMapper;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Application.EmailAccount.Queries;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email (enqueue to RabbitMQ)
/// </summary>
public record SendEmailCommand : IRequest<OutgoingEmailEvent>
public record SendEmailCommand : IRequest<Guid>
{
public required GetSenderQuery Sender { get; init; }
/// <summary>
/// Recipient email address
/// Recipient email addresses
/// </summary>
public required string Recipient { get; init; }
public required IEnumerable<string> Recipients { get; init; }
/// <summary>
/// Email subject
@@ -28,20 +33,40 @@ public record SendEmailCommand : IRequest<OutgoingEmailEvent>
/// Is HTML email (default: true)
/// </summary>
public bool IsHtml { get; init; } = true;
[JsonIgnore]
internal IEnumerable<EmailAttachmentContext> Attachments { get; private init; } = [];
/// <summary>
/// Returns a new command instance with the supplied attachments.
/// Called by the controller after resolving uploaded files.
/// </summary>
public SendEmailCommand WithAttachments(IEnumerable<EmailAttachmentContext> attachments)
=> this with { Attachments = attachments };
}
/// <summary>
/// Handler for SendEmailCommand
/// Creates EmailOutbox entity via AutoMapper and enqueues to RabbitMQ
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
/// </summary>
public class SendEmailCommandHandler(IOutgoingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, OutgoingEmailEvent>
public class SendEmailCommandHandler(ISender Sender, ISendingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, Guid>
{
public async Task<OutgoingEmailEvent> Handle(SendEmailCommand request, CancellationToken cancellationToken)
public async Task<Guid> Handle(SendEmailCommand request, CancellationToken cancellationToken)
{
var outgoingEmailEvent = Mapper.Map<OutgoingEmailEvent>(request);
var senderAccount = await Sender.Send(request.Sender, cancellationToken)
?? throw new NotFoundException(
$"No email account found for the given sender criteria (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
var email = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
// Enqueue to RabbitMQ
await Publisher.EnqueueAsync(outgoingEmailEvent, cancellationToken);
return outgoingEmailEvent;
var sendingEmailEvent = new SendingEmailEvent()
{
Id = Guid.NewGuid(),
Mail = email,
QueuedAt = DateTime.Now
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
}

View File

@@ -10,13 +10,16 @@ public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
{
public SendEmailCommandValidator()
{
RuleFor(x => x.Recipient)
RuleFor(x => x.Recipients)
.NotEmpty()
.WithMessage("Recipient is required")
.MaximumLength(200)
.WithMessage("Recipient must not exceed 200 characters")
.EmailAddress()
.WithMessage("Recipient must be a valid email address");
.WithMessage("Recipients are required")
.Must(x => x.Any())
.WithMessage("At least one recipient is required")
.ForEach(recipient => recipient
.NotEmpty()
.WithMessage("Recipient email must not be empty")
.EmailAddress()
.WithMessage("Invalid email address format"));
RuleFor(x => x.Subject)
.NotEmpty()

View File

@@ -1,14 +0,0 @@
using System.Threading;
using System.Threading.Tasks;
namespace DigitalData.MessagingService.Publisher.Abstraction;
/// <summary>
/// Email queue interface for outgoing emails.
/// </summary>
public interface IOutgoingEmailPublisher
{
Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default);
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -1,28 +0,0 @@
namespace DigitalData.MessagingService.Publisher.Abstraction;
public record OutgoingEmailEvent
{
public Guid Id { get; set; }
/// <summary>
/// Recipient email address
/// </summary>
public string Recipient { get; set; } = null!;
/// <summary>
/// Email subject
/// </summary>
public string Subject { get; set; } = null!;
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
public string Body { get; set; } = null!;
/// <summary>
/// Is HTML email (default: true)
/// </summary>
public bool IsHtml { get; set; } = true;
public DateTime QueuedAt { get; set; }
}

View File

@@ -33,7 +33,7 @@ public static class DependencyInjection
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
// --- Email Queue (RabbitMQ) ---
services.AddSingleton<OutgoingEmailConsumer>();
services.AddSingleton<SendingEmailConsumerPool>();
services.AddMessagingServicePublisher();
// --- RabbitMQ Configuration ---

View File

@@ -1,60 +1,71 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using RabbitMQ.Client.Events;
namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary>
/// RabbitMQ-based email queue implementation for outgoing emails.
/// Provides message persistence, scalability, and reliability.
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
/// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
/// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
/// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
/// </summary>
public sealed class OutgoingEmailConsumer : IAsyncDisposable
public sealed class SendingEmailConsumer : IAsyncDisposable
{
private readonly RabbitMqConfiguration _config;
private readonly string _queueName;
private readonly Lazy<Task<IChannel>> _lazyChannel;
private readonly Lazy<Task> _lazyInit;
private readonly ILogger<OutgoingEmailConsumer>? _logger;
private readonly ILogger<SendingEmailConsumer>? _logger;
public OutgoingEmailConsumer(IOptions<RabbitMqConfiguration> config, IEmailService EmailService, RabbitMqConnectionFactory CnnFactory, ILogger<OutgoingEmailConsumer>? logger = null)
/// <summary>
/// Transient identifier assigned to this consumer instance at runtime.
/// A new value is generated each time the application starts or a new consumer is created.
/// Use this to correlate log entries belonging to the same consumer session across competing instances.
/// </summary>
public Guid RuntimeId { get; } = Guid.NewGuid();
public SendingEmailConsumer(string queueName, IEmailService emailService, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
{
_logger = logger;
_config = config.Value;
_queueName = queueName;
_lazyChannel = new(CnnFactory.CreateChannelAsync);
_lazyInit = new(async () => {
_lazyChannel = new(cnnFactory.CreateChannelAsync);
_lazyInit = new(async () =>
{
var channel = await _lazyChannel.Value;
// prefetchCount=1 ensures this consumer processes one message at a time before acking.
// Parallelism comes from running multiple consumer instances, not from within a single channel.
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
var consumer = new AsyncEventingBasicConsumer(channel);
consumer.ReceivedAsync += async (sender, args) =>
{
OutgoingEmailEvent? oMailEvent = null;
SendingEmailEvent? oMailEvent = null;
try
{
var json = Encoding.UTF8.GetString(args.Body.ToArray());
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
oMailEvent = JsonSerializer.Deserialize<SendingEmailEvent>(json);
if (oMailEvent is not null)
{
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
await EmailService.SendEmailAsync(
oMailEvent.Recipient,
oMailEvent.Subject,
oMailEvent.Body,
isHtml: oMailEvent.IsHtml);
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
// Acknowledge message after successful processing
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
logger?.LogDebug(
"Email successfully sent and acknowledged. RuntimeId={RuntimeId}, Queue={QueueName}, DeliveryTag={DeliveryTag}, To={Recipients}, Subject={Subject}, EventId={EventId}",
RuntimeId, _queueName, args.DeliveryTag, oMailEvent.Mail.Recipients, oMailEvent.Mail.Subject, oMailEvent.Id);
}
else
{
@@ -64,11 +75,11 @@ public sealed class OutgoingEmailConsumer : IAsyncDisposable
}
catch (Exception ex)
{
logger?.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Recipient, oMailEvent?.Subject, args.DeliveryTag);
logger?.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Mail.Recipients, oMailEvent?.Mail.Subject, args.DeliveryTag);
// TODO: Error Reporting Strategy
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
// - Create EmailErrorReport entity { SendingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
// - Separate worker processes error queue → Log to DB/File/External monitoring
//
@@ -93,24 +104,23 @@ public sealed class OutgoingEmailConsumer : IAsyncDisposable
// Start consuming messages (event-driven, non-blocking)
await channel.BasicConsumeAsync(
queue: _config.QueueName,
queue: _queueName,
autoAck: false,
consumer: consumer,
cancellationToken: CnnFactory.CancellationToken);
cancellationToken: cnnFactory.CancellationToken);
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _queueName);
});
}
/// <summary>
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
/// Start event-driven consumer that processes messages as they arrive
/// Called lazily on first use via EnsureInitializedAsync.
/// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
/// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
/// </summary>
public async Task InitAsync()
{
if (_lazyInit.IsValueCreated)
_logger?.LogWarning("OutgoingEmailConsumer already initialized. InitAsync() called multiple times.");
_logger?.LogWarning("SendingEmailConsumer already initialized. InitAsync() called multiple times.");
await _lazyInit.Value;
}

View File

@@ -0,0 +1,51 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace DigitalData.MessagingService.Infrastructure.Queue;
/// <summary>
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
/// on the same RabbitMQ queue (competing consumers pattern).
/// Each consumer owns a dedicated channel, so they process messages fully in parallel
/// without any shared locking or synchronization primitives.
/// </summary>
public sealed class SendingEmailConsumerPool : IAsyncDisposable
{
private readonly List<SendingEmailConsumer> _consumers;
private readonly ILogger<SendingEmailConsumerPool>? _logger;
private readonly int _concurrency;
public SendingEmailConsumerPool(
IOptions<RabbitMqConfiguration> config,
IEmailService emailService,
RabbitMqConnectionFactory cnnFactory,
ILogger<SendingEmailConsumerPool>? logger = null,
ILogger<SendingEmailConsumer>? consumerLogger = null)
{
_logger = logger;
_concurrency = config.Value.ConsumerConcurrency;
_consumers = [.. Enumerable
.Range(0, _concurrency)
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, cnnFactory, consumerLogger))];
}
/// <summary>
/// Starts all consumers in parallel. Each consumer opens its own channel and begins listening.
/// </summary>
public async Task InitAsync()
{
_logger?.LogInformation("Starting {Count} competing email consumers.", _concurrency);
await Task.WhenAll(_consumers.Select(c => c.InitAsync()));
_logger?.LogInformation("All {Count} email consumers started.", _concurrency);
}
public async ValueTask DisposeAsync()
{
await Task.WhenAll(_consumers.Select(async c => await c.DisposeAsync().AsTask()));
}
}

View File

@@ -1,6 +1,6 @@
using System.Linq.Expressions;
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;

View File

@@ -1,19 +1,18 @@
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Infrastructure.Queue;
using Microsoft.Extensions.Hosting;
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
/// <summary>
/// A hosted background service responsible for initializing the outgoing email queue consumer.
/// A hosted background service responsible for initializing the competing email consumer pool.
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
/// </summary>
public class AsyncInitWorker(OutgoingEmailConsumer EmailConsumer) : BackgroundService
public class AsyncInitWorker(SendingEmailConsumerPool ConsumerPool) : BackgroundService
{
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
await EmailConsumer.InitAsync();
await ConsumerPool.InitAsync();
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
}

View File

@@ -0,0 +1,16 @@
using Limilabs.Client.SMTP;
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
public static class SmtpExtensions
{
public static async Task CloseSafelyAsync(this Smtp smtp)
{
try
{
if (smtp.Connected)
await smtp.CloseAsync();
}
catch { /* Ignore disconnect errors */ }
}
}

View File

@@ -1,111 +1,142 @@
using System.Text;
using DigitalData.MessagingService.Application.Common.Dtos;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Domain.Exceptions;
using Limilabs.Client.SMTP;
using Limilabs.Mail;
using Limilabs.Mail.Headers;
using Microsoft.Extensions.Options;
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Infrastructure.Services;
/// <summary>
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
/// Commercial-grade library with superior Exchange support.
/// SMTP configuration is injected via IOptions&lt;EmailAccountDto&gt; from appsettings.json.
/// SMTP configuration is injected via IOptions&lt;EmailAccountsOptions&gt; from appsettings.json.
/// Uses the first account in the list whose <see cref="EmailAccountDto.Name"/> equals <c>"default"</c>,
/// or falls back to the first account if none is named "default".
/// </summary>
public class LimilabsEmailService(
IEncryptionService encryptionService,
IOptions<EmailAccountDto> smtpConfig) : IEmailService
IEncryptionService encryptionService) : IEmailService
{
private readonly EmailAccountDto _smtpAccount = smtpConfig.Value;
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
static LimilabsEmailService()
{
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
}
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default)
public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
{
using var smtp = new Smtp();
ISendMessageResult? result = null;
try
{
await ConnectAndAuthenticateSmtpAsync(smtp);
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
var builder = new MailBuilder();
builder.From.Add(new MailBox(_smtpAccount.Username));
builder.To.Add(new MailBox(to));
builder.Subject = subject;
builder.From.Add(new MailBox(context.Sender.Username));
if (isHtml)
{
builder.Html = body;
}
foreach (var recipient in context.Recipients)
builder.To.Add(new MailBox(recipient));
builder.Subject = context.Subject;
if (context.IsHtml)
builder.Html = context.Body;
else
{
builder.Text = body;
}
builder.Text = context.Body;
AddAttachments(builder, context.Attachments);
var mail = builder.Create();
var result = smtp.SendMessage(mail);
result = await smtp.SendMessageAsync(mail, cancellationToken);
if (result.Status != SendMessageStatus.Success)
{
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}");
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}. {ErrorMessageBuilder(result)}");
}
smtp.Close();
await Task.CompletedTask; // For async consistency
await smtp.CloseAsync(cancellationToken);
}
catch (Limilabs.Client.ServerException ex)
{
DisconnectSafely(smtp);
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
await smtp.CloseSafelyAsync();
throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", ex);
}
catch (Exception ex)
{
DisconnectSafely(smtp);
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
await smtp.CloseSafelyAsync();
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
}
}
// --- Private Helper Methods ---
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp)
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccountDto smtpAccount)
{
if (_smtpAccount.SmtpUseSsl)
if (smtpAccount.SmtpUseSsl)
{
smtp.ConnectSSL(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
}
else
{
smtp.Connect(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
}
if (_smtpAccount.UseOAuth2)
if (smtpAccount.UseOAuth2)
{
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false.");
}
else
{
var password = _smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(_smtpAccount.Password) : _smtpAccount.Password;
var password = smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(smtpAccount.Password) : smtpAccount.Password;
smtp.Login(_smtpAccount.Username, password);
await smtp.LoginAsync(smtpAccount.Username, password);
}
await Task.CompletedTask; // For async consistency
}
private static void DisconnectSafely(Smtp smtp)
private static string ErrorMessageBuilder(ISendMessageResult? result = null)
{
try
if(result is null || result.GeneralErrors.Count == 0)
return string.Empty;
else if(result.GeneralErrors.Count == 1)
return $"Error: {result.GeneralErrors.FirstOrDefault()}";
var message = new StringBuilder("Errors:\n");
foreach (var error in result.GeneralErrors)
{
if (smtp.Connected)
smtp.Close();
message.AppendLine($" • {error}");
}
catch { /* Ignore disconnect errors */ }
return message.ToString();
}
}
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentContext> attachments)
{
foreach (var attachment in attachments)
{
if (attachment.IsInline)
{
var visual = builder.AddVisual(attachment.Content);
visual.FileName = attachment.FileName;
visual.ContentId = string.IsNullOrWhiteSpace(attachment.ContentId)
? attachment.FileName
: attachment.ContentId;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
visual.ContentType = ContentType.Parse(attachment.ContentType);
}
else
{
var part = builder.AddAttachment(attachment.Content);
part.FileName = attachment.FileName;
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
part.ContentType = ContentType.Parse(attachment.ContentType);
if (!string.IsNullOrWhiteSpace(attachment.ContentId))
part.ContentId = attachment.ContentId;
}
}
}
}

View File

@@ -1,4 +1,4 @@
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -13,7 +13,7 @@ public static class DependencyInjection
services.AddRabbitMqConnectionFactory(configure);
// --- Email Queue (RabbitMQ) ---
services.AddSingleton<IOutgoingEmailPublisher, OutgoingEmailPublisher>();
services.AddSingleton<ISendingEmailPublisher, SendingEmailPublisher>();
return services;
}

View File

@@ -12,7 +12,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj" />
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj" />
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
</ItemGroup>

View File

@@ -4,7 +4,7 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using RabbitMQ.Client;
using DigitalData.MessagingService.RabbitMQ;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Publisher;
@@ -13,14 +13,14 @@ namespace DigitalData.MessagingService.Publisher;
/// Provides message persistence, scalability, and reliability.
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
/// </summary>
public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisposable
public sealed class SendingEmailPublisher : ISendingEmailPublisher, IAsyncDisposable
{
private readonly RabbitMqConfiguration _config;
private readonly ILogger<OutgoingEmailPublisher> _logger;
private readonly ILogger<SendingEmailPublisher> _logger;
private readonly RabbitMqConnectionFactory _cnnFactory;
private readonly Lazy<Task<IChannel>> _lazyChannel;
public OutgoingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory)
public SendingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<SendingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory)
{
_config = config.Value;
_logger = logger;
@@ -66,9 +66,9 @@ public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisp
return channel;
}
public async Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default)
public async Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default)
{
var json = JsonSerializer.Serialize(outgoingEmailEvent);
var json = JsonSerializer.Serialize(sendingEmailEvent);
var body = Encoding.UTF8.GetBytes(json);
var properties = new BasicProperties

View File

@@ -75,5 +75,11 @@ namespace DigitalData.MessagingService.RabbitMQ
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
/// </summary>
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
/// <summary>
/// Maximum number of email messages processed concurrently by the consumer.
/// Maps directly to RabbitMQ prefetchCount. Recommended: 35.
/// </summary>
public ushort ConsumerConcurrency { get; set; } = 5;
}
}

View File

@@ -1,36 +1,64 @@
using DigitalData.MessagingService.Application.EmailSending.Commands;
using DigitalData.MessagingService.Abstraction;
using MediatR;
using Microsoft.AspNetCore.Mvc;
namespace DigitalData.MessagingService.API.Controllers;
/// <summary>
/// Email sending API controller
/// Enqueues outgoing emails to RabbitMQ for async processing
/// Email sending API controller.
/// Enqueues outgoing emails to RabbitMQ for async processing.
/// </summary>
[ApiController]
[Route("api/[controller]")]
public class EmailsController(IMediator mediator) : ControllerBase
{
/// <summary>
/// Send email (enqueue to RabbitMQ for background processing)
/// Send an email, optionally with file attachments.
/// Omit the <c>attachments</c> field for a plain send.
/// </summary>
/// <param name="command">Send email command</param>
/// <param name="command">Email fields as form values</param>
/// <param name="attachments">Optional uploaded files</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>HTTP 202 Accepted (queued for processing)</returns>
[HttpPost("send")]
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
[HttpPost]
[Consumes("multipart/form-data")]
[ProducesResponseType(StatusCodes.Status202Accepted)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> SendEmail([FromBody] SendEmailCommand command, CancellationToken cancellationToken)
public async Task<IActionResult> SendEmail(
[FromForm] SendEmailCommand command,
IFormFileCollection? attachments,
CancellationToken cancellationToken)
{
var outgoingEmailEvent = await mediator.Send(command, cancellationToken);
var commandWithAttachments = command.WithAttachments(
await BuildAttachmentsAsync(attachments, cancellationToken));
return Accepted(new
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
return Accepted(new { Id = eventId });
}
private static async Task<IEnumerable<EmailAttachmentContext>> BuildAttachmentsAsync(
IFormFileCollection? files,
CancellationToken cancellationToken)
{
if (files is null || files.Count == 0)
return [];
var result = new List<EmailAttachmentContext>(files.Count);
foreach (var file in files)
{
CommandId = outgoingEmailEvent.Id,
To = outgoingEmailEvent.Recipient,
outgoingEmailEvent.Subject,
outgoingEmailEvent.QueuedAt
});
using var ms = new MemoryStream();
await file.CopyToAsync(ms, cancellationToken);
result.Add(new EmailAttachmentContext
{
FileName = file.FileName,
Content = ms.ToArray(),
ContentType = file.ContentType
});
}
return result;
}
}

View File

@@ -36,4 +36,8 @@
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Infrastructure\Swagger\" />
</ItemGroup>
</Project>

View File

@@ -1,6 +1,5 @@
using DigitalData.MessagingService.API.Middleware;
using DigitalData.MessagingService.Application;
using DigitalData.MessagingService.Application.Common.Dtos;
using DigitalData.MessagingService.Infrastructure;
using Serilog;
using Serilog.Ui.Core.Extensions;
@@ -67,10 +66,6 @@ try
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
builder.Services.AddInfrastructure(builder.Configuration);
// Register EmailAccount configuration (IOptions<EmailAccountDto>)
builder.Services.Configure<EmailAccountDto>(
builder.Configuration.GetSection("EmailAccount"));
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();

View File

@@ -4,5 +4,16 @@
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"Serilog": {
"MinimumLevel": {
"Default": "Debug",
"Override": {
"Microsoft": "Warning",
"Microsoft.AspNetCore": "Warning",
"Microsoft.EntityFrameworkCore": "Warning",
"System": "Warning"
}
}
}
}

View File

@@ -1,27 +0,0 @@
{
"AllowedHosts": "*",
"RabbitMQ": {
"HostName": "172.24.12.56",
"Port": 5672,
"UserName": "admin",
"Password": "fl!'D}4;pYBb\\VD&{6]]G*\\0Bq8fVIn0j?Sgm\\2A,6GS47g5Dj",
"VirtualHost": "/",
"AutomaticRecoveryEnabled": true,
"NetworkRecoveryIntervalSeconds": 10,
"QueueName": "emailprofiler.email.outbox",
"ExchangeName": "emailprofiler.emails",
"RoutingKey": "email.outbox",
"DlqQueueName": "emailprofiler.email.outbox.dlq",
"DlqExchangeName": "emailprofiler.emails.dlq",
"DlqRoutingKey": "email.outbox.dlq"
},
"EmailAccount": {
"Username": "test-flow@digitaldata.works",
"Password": "ddemail108",
"PasswordEncrypted": false,
"SmtpServer": "kundencenter.triplew.de",
"SmtpPort": 465,
"SmtpUseSsl": true,
"UseOAuth2": false
}
}

View File

@@ -35,8 +35,15 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj" />
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Abstraction\DigitalData.MessagingService.Abstraction.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj">
<PrivateAssets>all</PrivateAssets>
</ProjectReference>
</ItemGroup>
</Project>

View File

@@ -1,46 +0,0 @@
using DigitalData.MessagingService.Publisher.Abstraction;
namespace DigitalData.MessagingService.Client;
/// <summary>
/// Represents an outgoing email message to be sent through the messaging service.
/// </summary>
public record Email
{
/// <summary>
/// Recipient email address
/// </summary>
public string Recipient { get; set; } = null!;
/// <summary>
/// Email subject
/// </summary>
public string Subject { get; set; } = null!;
/// <summary>
/// Email body (HTML or plain text)
/// </summary>
public string Body { get; set; } = null!;
/// <summary>
/// Is HTML email (default: true)
/// </summary>
public bool IsHtml { get; set; } = true;
/// <summary>
/// Converts this <see cref="Email"/> instance to an <see cref="OutgoingEmailEvent"/>.
/// </summary>
/// <returns>A new <see cref="OutgoingEmailEvent"/> representing this email.</returns>
internal OutgoingEmailEvent ToEvent()
{
return new OutgoingEmailEvent
{
Id = Guid.NewGuid(),
Recipient = Recipient,
Subject = Subject,
Body = Body,
IsHtml = IsHtml,
QueuedAt = DateTime.Now
};
}
}

View File

@@ -1,5 +1,5 @@
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
@@ -113,18 +113,23 @@ public static class EmailSender
/// Thrown when <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> has not been called prior to sending.
/// </exception>
/// <remarks>
/// This method maps <see cref="Email"/> to <see cref="OutgoingEmailEvent"/>,
/// then resolves <see cref="IOutgoingEmailPublisher"/> from the internal
/// This method maps <see cref="EmailContext"/> to <see cref="SendingEmailEvent"/>,
/// then resolves <see cref="ISendingEmailPublisher"/> from the internal
/// service provider and calls <c>EnqueueAsync</c> in a fire-and-forget manner.
/// Ensure that any unhandled exceptions from the async operation are handled
/// at the publisher level.
/// </remarks>
public static void Send(Email email)
public static void Send(EmailContext email)
{
if(!IsConnected)
throw new InvalidOperationException("Messaging service is not connected. Call ConnectRabbitMq first.");
var publisher = LazyProvider.Value.GetRequiredService<IOutgoingEmailPublisher>();
publisher.EnqueueAsync(email.ToEvent());
var publisher = LazyProvider.Value.GetRequiredService<ISendingEmailPublisher>();
publisher.EnqueueAsync(new SendingEmailEvent()
{
Id = Guid.NewGuid(),
Mail = email,
QueuedAt = DateTime.Now
});
}
}

View File

@@ -1,5 +1,5 @@
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Tests.Integration;
@@ -75,9 +75,10 @@ public sealed class EmailSenderTests
[Fact]
public void Send_WithValidEmail_DoesNotThrow()
{
var email = new Email
var email = new EmailContext
{
Recipient = "hakanttek@gmail.com",
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["hakanttek@gmail.com"],
Subject = "EmailSender.Send Integration Test",
Body = "<p>Sent via EmailSender static client.</p>",
IsHtml = true,
@@ -91,9 +92,10 @@ public sealed class EmailSenderTests
[Fact]
public void Send_WithPlainTextBody_DoesNotThrow()
{
var email = new Email
var email = new EmailContext
{
Recipient = "hakanttek@gmail.com",
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["hakanttek@gmail.com"],
Subject = "Plain Text Test",
Body = "This is a plain text email.",
IsHtml = false

View File

@@ -1,5 +1,5 @@
using DigitalData.MessagingService.Client;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
namespace DigitalData.MessagingService.Tests.Integration;
@@ -55,9 +55,10 @@ public sealed class EmailSenderUrlOverloadTests
[Fact]
public void Send_AfterUrlOverloadConnection_DoesNotThrow()
{
var email = new Email
var email = new EmailContext
{
Recipient = "url-overload-test@example.com",
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["url-overload-test@example.com"],
Subject = "URL Overload Integration Test",
Body = "<p>Sent after URL-based connection.</p>",
IsHtml = true

View File

@@ -1,7 +1,7 @@
using System.Text;
using System.Text.Json;
using DigitalData.MessagingService.Publisher;
using DigitalData.MessagingService.Publisher.Abstraction;
using DigitalData.MessagingService.Abstraction;
using DigitalData.MessagingService.RabbitMQ;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
@@ -10,37 +10,41 @@ using RabbitMQ.Client;
namespace DigitalData.MessagingService.Tests.Integration;
/// <summary>
/// Integration tests for <see cref="OutgoingEmailPublisher"/> against the real RabbitMQ broker.
/// Integration tests for <see cref="SendingEmailPublisher"/> against the real RabbitMQ broker.
/// Each test publishes a message and immediately reads it back via BasicGetAsync to verify
/// the full round-trip without starting the consumer (which requires Limilabs Mail.dll).
/// </summary>
public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
public sealed class SendingEmailPublisherTests : IAsyncDisposable
{
private readonly ServiceProvider _serviceProvider;
private readonly IOutgoingEmailPublisher _publisher;
private readonly ISendingEmailPublisher _publisher;
private readonly RabbitMqConnectionFactory _factory;
public OutgoingEmailPublisherTests()
public SendingEmailPublisherTests()
{
var services = new ServiceCollection();
services.AddLogging();
services.AddMessagingServicePublisher(RabbitMqTestConfig.Apply);
_serviceProvider = services.BuildServiceProvider();
_publisher = _serviceProvider.GetRequiredService<IOutgoingEmailPublisher>();
_publisher = _serviceProvider.GetRequiredService<ISendingEmailPublisher>();
_factory = _serviceProvider.GetRequiredService<RabbitMqConnectionFactory>();
}
[Fact]
public async Task EnqueueAsync_PublishesMessage_MessageArrivesInQueue()
{
var email = new OutgoingEmailEvent
var email = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Recipient = "test@example.com",
Subject = "Integration Test - EnqueueAsync",
Body = "<p>Hello from integration test.</p>",
IsHtml = true,
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["test@example.com"],
Subject = "Integration Test - EnqueueAsync",
Body = "<p>Hello from integration test.</p>",
IsHtml = true,
},
QueuedAt = DateTime.Now
};
@@ -59,13 +63,17 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
[Fact]
public async Task EnqueueAsync_MultipleMessages_AllArrivesInQueue()
{
var emails = Enumerable.Range(1, 3).Select(i => new OutgoingEmailEvent
var emails = Enumerable.Range(1, 3).Select(i => new SendingEmailEvent
{
Id = Guid.NewGuid(),
Recipient = $"recipient{i}@example.com",
Subject = $"Integration Test - Batch #{i}",
Body = $"Batch message {i}",
IsHtml = false,
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = new List<string> { $"recipient{i}@example.com" },
Subject = $"Integration Test - Batch #{i}",
Body = $"Batch message {i}",
IsHtml = false,
},
QueuedAt = DateTime.Now
}).ToList();
@@ -82,13 +90,17 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
[Fact]
public async Task GetQueueDepthAsync_AfterPublish_ReturnsPositiveDepth()
{
var email = new OutgoingEmailEvent
var email = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Recipient = "depth-test@example.com",
Subject = "Integration Test - GetQueueDepth",
Body = "Queue depth test",
IsHtml = false,
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = ["depth-test@example.com"],
Subject = "Integration Test - GetQueueDepth",
Body = "Queue depth test",
IsHtml = false,
},
QueuedAt = DateTime.Now
};
@@ -105,13 +117,17 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
{
var id = Guid.NewGuid();
var email = new OutgoingEmailEvent
var email = new SendingEmailEvent
{
Id = id,
Recipient = "serialize@example.com",
Subject = "Serialization Test",
Body = "<strong>Bold</strong>",
IsHtml = true,
Mail = new EmailContext
{
Sender = new EmailAccountDto { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
Recipients = new List<string> { "serialize@example.com" },
Subject = "Serialization Test",
Body = "<strong>Bold</strong>",
IsHtml = true,
},
QueuedAt = DateTime.Now
};
@@ -123,16 +139,16 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
Assert.NotNull(received);
Assert.Equal(id, received.Id);
Assert.Equal("serialize@example.com", received.Recipient);
Assert.Equal("Serialization Test", received.Subject);
Assert.Equal("<strong>Bold</strong>", received.Body);
Assert.True(received.IsHtml);
Assert.Equal(["serialize@example.com"], received.Mail.Recipients);
Assert.Equal("Serialization Test", received.Mail.Subject);
Assert.Equal("<strong>Bold</strong>", received.Mail.Body);
Assert.True(received.Mail.IsHtml);
}
/// <summary>
/// Reads a single message from the queue without acknowledging it (peek via nack+requeue).
/// </summary>
private async Task<OutgoingEmailEvent?> PeekMessageAsync()
private async Task<SendingEmailEvent?> PeekMessageAsync()
{
var connection = await _factory.GetDefaultConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
@@ -146,21 +162,21 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
await channel.BasicNackAsync(result.DeliveryTag, multiple: false, requeue: true);
var json = Encoding.UTF8.GetString(result.Body.ToArray());
return JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
return JsonSerializer.Deserialize<SendingEmailEvent>(json);
}
/// <summary>
/// Scans queue messages (up to a limit) to find a message matching the given <paramref name="id"/>.
/// All messages are re-queued after inspection.
/// </summary>
private async Task<OutgoingEmailEvent?> FindMessageAsync(Guid id, int maxMessages = 50)
private async Task<SendingEmailEvent?> FindMessageAsync(Guid id, int maxMessages = 50)
{
var connection = await _factory.GetDefaultConnectionAsync();
await using var channel = await connection.CreateChannelAsync();
var requeue = new List<(ulong DeliveryTag, byte[] Body)>();
OutgoingEmailEvent? found = null;
SendingEmailEvent? found = null;
for (int i = 0; i < maxMessages; i++)
{
@@ -171,7 +187,7 @@ public sealed class OutgoingEmailPublisherTests : IAsyncDisposable
requeue.Add((result.DeliveryTag, result.Body.ToArray()));
var json = Encoding.UTF8.GetString(result.Body.ToArray());
var evt = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
var evt = JsonSerializer.Deserialize<SendingEmailEvent>(json);
if (evt?.Id == id)
{