Compare commits
19 Commits
e6df623538
...
2e69fac250
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e69fac250 | |||
| 890c32f1c8 | |||
| fa9b4973b9 | |||
| bd31bfe528 | |||
| dbf78653b4 | |||
| 28f7a3607a | |||
| 42b30d4ac4 | |||
| 3cd8841e80 | |||
| c6e67c0f99 | |||
| 66afdefbd8 | |||
| 58ad50b96b | |||
| e550db8789 | |||
| 601fd9be5f | |||
| 0d9d15032f | |||
| e47333cd1d | |||
| 740bb8c313 | |||
| 7fa3c4888a | |||
| e0e399f5ed | |||
| e53fabcda2 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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. <img src="cid:logo">).
|
||||
/// 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; }
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
namespace DigitalData.MessagingService.Publisher.Abstraction;
|
||||
namespace DigitalData.MessagingService.Abstraction;
|
||||
|
||||
public record OutgoingEmailCreateDto
|
||||
public record SendingEmailCreateDto
|
||||
{
|
||||
/// <summary>
|
||||
/// Recipient email address
|
||||
@@ -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
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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.");
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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 ---
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
}
|
||||
@@ -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<EmailAccountDto> from appsettings.json.
|
||||
/// SMTP configuration is injected via IOptions<EmailAccountsOptions> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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: 3–5.
|
||||
/// </summary>
|
||||
public ushort ConsumerConcurrency { get; set; } = 5;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,8 @@
|
||||
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Infrastructure\Swagger\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -4,5 +4,16 @@
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Debug",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"Microsoft.AspNetCore": "Warning",
|
||||
"Microsoft.EntityFrameworkCore": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
Reference in New Issue
Block a user