Compare commits
84 Commits
6183ea613f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| d01a0ceaa6 | |||
| fd234618f7 | |||
| f99bd8c399 | |||
| c0bd391297 | |||
| cda70c8ced | |||
| 578ecc7ba1 | |||
| 6abe0e18f6 | |||
| c9b7d99ecc | |||
| 4964fa4344 | |||
| 7207c5b4d9 | |||
| 7f04c4b09f | |||
| 43d7c393bb | |||
| e73bead2f2 | |||
| 606ff94a77 | |||
| 1c9af0e560 | |||
| 86a07e5017 | |||
| 25d0c009ee | |||
| 14180860d3 | |||
| 50eaefb868 | |||
| 84da5cb646 | |||
| 25b55ef651 | |||
| 8b4d1e48f5 | |||
| 893addb45d | |||
| 00ae8e1ba0 | |||
| 5c37b2ef92 | |||
| be28a61d9c | |||
| b2857c558f | |||
| 91581649ba | |||
| 4dd0974c6f | |||
| 5e0de6d52a | |||
| 176e6dd6c5 | |||
| 48796d9917 | |||
| f6ada2ad9e | |||
| 7b5596f3e5 | |||
| 5d3997b7e1 | |||
| cb3b2e09ab | |||
| 118612206e | |||
| a446162afa | |||
| f521683608 | |||
| f3761e96d9 | |||
| e840d026aa | |||
| 962cb52e0b | |||
| 1162a07454 | |||
| 59e8780345 | |||
| 5ff38391d0 | |||
| 1fb7dcf98a | |||
| ee279c407b | |||
| 74ec00ddd3 | |||
| 763ba67d34 | |||
| 8e2e9af451 | |||
| 53ba40b316 | |||
| 52a416f6d9 | |||
| 79db1c3a69 | |||
| 18c5dca9f4 | |||
| c47d78112c | |||
| 20de7da93d | |||
| 2e69fac250 | |||
| 890c32f1c8 | |||
| fa9b4973b9 | |||
| bd31bfe528 | |||
| dbf78653b4 | |||
| 28f7a3607a | |||
| 42b30d4ac4 | |||
| 3cd8841e80 | |||
| c6e67c0f99 | |||
| 66afdefbd8 | |||
| 58ad50b96b | |||
| e550db8789 | |||
| 601fd9be5f | |||
| 0d9d15032f | |||
| e47333cd1d | |||
| 740bb8c313 | |||
| 7fa3c4888a | |||
| e0e399f5ed | |||
| e53fabcda2 | |||
| e6df623538 | |||
| 7773c8aa7f | |||
| c73d8d8f36 | |||
| 2f39db94ca | |||
| e78ceb522c | |||
| d7878d8ff8 | |||
| 868c447a6c | |||
| 220d3f441f | |||
| 5d9197f54a |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -371,3 +371,5 @@ FodyWeavers.xsd
|
|||||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||||
/legacy/App
|
/legacy/App
|
||||||
/src/DigitalData.MessagingService.API/appsettings.Secrets.json
|
/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);
|
arguments: null);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task EnqueueAsync(OutgoingEmail email, CancellationToken cancellationToken)
|
public async Task EnqueueAsync(SendingEmail email, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(email);
|
var json = JsonSerializer.Serialize(email);
|
||||||
var body = Encoding.UTF8.GetBytes(json);
|
var body = Encoding.UTF8.GetBytes(json);
|
||||||
@@ -347,7 +347,7 @@ return Accepted(); // HTTP 202 - command queued for processing
|
|||||||
await Task.CompletedTask;
|
await Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<OutgoingEmail?> DequeueAsync(CancellationToken cancellationToken)
|
public async Task<SendingEmail?> DequeueAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = _channel.BasicGet(QueueName, autoAck: false);
|
var result = _channel.BasicGet(QueueName, autoAck: false);
|
||||||
|
|
||||||
@@ -355,7 +355,7 @@ return Accepted(); // HTTP 202 - command queued for processing
|
|||||||
return null;
|
return null;
|
||||||
|
|
||||||
var json = Encoding.UTF8.GetString(result.Body.ToArray());
|
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);
|
_channel.BasicAck(result.DeliveryTag, false);
|
||||||
|
|
||||||
|
|||||||
@@ -27,18 +27,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingServic
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
|
||||||
EndProject
|
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}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Client.DependencyInjection", "src\presentation\DigitalData.MessagingService.Client.DependencyInjection\DigitalData.MessagingService.Client.DependencyInjection.csproj", "{B67C6FA8-DA47-41EC-B15A-511C5B19C036}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher", "src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj", "{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher", "src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj", "{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}"
|
||||||
EndProject
|
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
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -57,10 +55,6 @@ Global
|
|||||||
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{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.ActiveCfg = Release|Any CPU
|
||||||
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Release|Any CPU.Build.0 = 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.ActiveCfg = Debug|Any CPU
|
||||||
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = 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
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
@@ -73,14 +67,14 @@ Global
|
|||||||
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{B67C6FA8-DA47-41EC-B15A-511C5B19C036}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599}.Debug|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{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
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
HideSolutionNode = FALSE
|
||||||
@@ -92,12 +86,11 @@ Global
|
|||||||
{71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
{71BEA4D0-7835-4A8C-B11E-1088E0801DCE} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
{7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
|
{7CBE8648-F259-CC91-87FF-5859280867A8} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
|
||||||
{8E44FA5B-43DD-E273-C682-FC382A854A6D} = {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}
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {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}
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
{B67C6FA8-DA47-41EC-B15A-511C5B19C036} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
|
||||||
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||||
|
|||||||
1
legacy
Submodule
1
legacy
Submodule
Submodule legacy added at e59b936181
@@ -0,0 +1,46 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record 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 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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailAccountModificationDto
|
||||||
|
{
|
||||||
|
#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
|
||||||
|
|
||||||
|
#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; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single email attachment.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EmailAttachmentDto
|
||||||
|
{
|
||||||
|
/// <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,57 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record EmailContext
|
||||||
|
{
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public EmailAccount Sender { get; set; } = null!;
|
||||||
|
#else
|
||||||
|
public required EmailAccount 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="EmailAttachmentDto.Content"/>)
|
||||||
|
/// or reference a file on disk via <see cref="EmailAttachmentDto.FilePath"/>.
|
||||||
|
/// </summary>
|
||||||
|
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains the search to messages within an arrival-date range.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// At least one of <see cref="After"/> or <see cref="Before"/> must be provided.
|
||||||
|
/// When both are set <see cref="After"/> must be earlier than <see cref="Before"/>.
|
||||||
|
/// Both bounds are <b>inclusive</b>.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="After">Earliest date to include (inclusive). Maps to IMAP <c>SINCE</c>.</param>
|
||||||
|
/// <param name="Before">Latest date to include (inclusive). Maps to IMAP <c>BEFORE</c> (next day is used internally).</param>
|
||||||
|
public record DateFilter(DateTime? After = null, DateTime? Before = null);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Describes all criteria and options used when searching an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// All text-match properties are <b>case-insensitive</b> substring searches performed server-side via IMAP SEARCH.
|
||||||
|
/// Combine multiple criteria freely; an implicit AND is applied across all non-null fields.
|
||||||
|
/// </remarks>
|
||||||
|
public record MailSearchFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Mailbox folder to search. Defaults to <c>"INBOX"</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string Folder { get; init; } = "INBOX";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/>, only unread (UNSEEN) messages are returned.
|
||||||
|
/// </summary>
|
||||||
|
public bool UnseenOnly { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When <see langword="true"/>, attachment data is included in the results; otherwise attachments are omitted.
|
||||||
|
/// Defaults to <see langword="false"/>.
|
||||||
|
/// </summary>
|
||||||
|
public bool WithAttachments { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Maximum number of messages to retrieve. <c>0</c> means unlimited.
|
||||||
|
/// Applied after sorting; defaults to <c>50</c>.
|
||||||
|
/// </summary>
|
||||||
|
public int? MaxCount { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls the order of the returned messages. Defaults to <see cref="MailSortOrder.NewestFirst"/>.
|
||||||
|
/// </summary>
|
||||||
|
public MailSortOrder SortOrder { get; init; } = MailSortOrder.NewestFirst;
|
||||||
|
|
||||||
|
// ── Text filters ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring the message subject must contain.
|
||||||
|
/// Maps to IMAP <c>SUBJECT</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? SubjectContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear in the <c>From</c> header.
|
||||||
|
/// Maps to IMAP <c>FROM</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? SenderContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear in <c>To</c> or <c>Cc</c>.
|
||||||
|
/// Maps to IMAP <c>TO</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? RecipientContains { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Case-insensitive substring that must appear anywhere in the message body (text or HTML part).
|
||||||
|
/// Maps to IMAP <c>BODY</c>.
|
||||||
|
/// </summary>
|
||||||
|
public string? BodyContains { get; init; } = null;
|
||||||
|
|
||||||
|
// ── Structured filters ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains results to a specific UID or a UID range.
|
||||||
|
/// See <see cref="UidFilter"/> for mutual-exclusion rules between its fields.
|
||||||
|
/// </summary>
|
||||||
|
public UidFilter? Uid { get; init; } = null;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains results to messages received within a date range.
|
||||||
|
/// See <see cref="DateFilter"/> for rules between its fields.
|
||||||
|
/// </summary>
|
||||||
|
public DateFilter? Date { get; init; } = null;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Controls the order in which fetched messages are returned.
|
||||||
|
/// </summary>
|
||||||
|
public enum MailSortOrder
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Newest messages first (default).
|
||||||
|
/// </summary>
|
||||||
|
NewestFirst,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Oldest messages first.
|
||||||
|
/// </summary>
|
||||||
|
OldestFirst
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Constrains the search to a specific UID or a UID range.
|
||||||
|
/// </summary>
|
||||||
|
/// <remarks>
|
||||||
|
/// Use <see cref="Absolute"/> for an exact single-message lookup.
|
||||||
|
/// Use <see cref="Min"/> and/or <see cref="Max"/> for an open or closed range.
|
||||||
|
/// Mixing <see cref="Absolute"/> with <see cref="Min"/> or <see cref="Max"/> is not allowed.
|
||||||
|
/// </remarks>
|
||||||
|
/// <param name="Min">Lower bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||||
|
/// <param name="Max">Upper bound of the UID range (inclusive). Ignored when <see cref="Absolute"/> is set.</param>
|
||||||
|
/// <param name="Absolute">Exact UID to match. When set, <see cref="Min"/> and <see cref="Max"/> must be <see langword="null"/>.</param>
|
||||||
|
public record UidFilter(long? Min = null, long? Max = null, long? Absolute = null);
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an email message received via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
public sealed record ReceivedEmailDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier of the message on the IMAP server (UID).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public long Uid { get; init; }
|
||||||
|
#else
|
||||||
|
public long Uid { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account this message belongs to.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public int AccountId { get; init; }
|
||||||
|
#else
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender address (From header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string From { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient addresses (To header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<string> To { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<string> To { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC addresses.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<string> Cc { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<string> Cc { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-text body (may be empty when only HTML is present).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string TextBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string TextBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTML body (may be empty when only plain-text is present).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public string HtmlBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date/time the message was sent (Date header).
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public DateTime Date { get; init; }
|
||||||
|
#else
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attachments included with this message.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<EmailAttachmentDto> Attachments { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public IEnumerable<EmailAttachmentDto> Attachments { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the message has been marked as seen/read on the server.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public bool IsSeen { get; init; }
|
||||||
|
#else
|
||||||
|
public bool IsSeen { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -1,11 +1,7 @@
|
|||||||
using System;
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Publisher.Abstraction;
|
public record SendingEmailCreateDto
|
||||||
|
|
||||||
public class OutgoingEmailEvent
|
|
||||||
{
|
{
|
||||||
public Guid Id { get; set; }
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recipient email address
|
/// Recipient email address
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
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.Application.Common.Dto;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -12,5 +14,5 @@ public interface IEmailService
|
|||||||
/// Sends an email using the configured SMTP account.
|
/// Sends an email using the configured SMTP account.
|
||||||
/// SMTP credentials are configured in appsettings.json (EmailAccount section).
|
/// SMTP credentials are configured in appsettings.json (EmailAccount section).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default);
|
Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Service interface for reading emails via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
public interface IImapEmailService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account"></param>
|
||||||
|
/// <param name="folder"></param>
|
||||||
|
/// <param name="cancel"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
Task<EmailSyncResult> SyncEmailsAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
string folder = "INBOX",
|
||||||
|
CancellationToken cancel = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks a message as seen (read) on the server.
|
||||||
|
/// </summary>
|
||||||
|
Task MarkAsSeenAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
long uid,
|
||||||
|
string folder = "INBOX",
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last IMAP sync date for the specified account and folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId"></param>
|
||||||
|
/// <param name="folder"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX");
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email queue interface for outgoing emails.
|
||||||
|
/// </summary>
|
||||||
|
public interface ISendingEmailPublisher
|
||||||
|
{
|
||||||
|
Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
|
||||||
|
public interface IReceivedEmailRepository : IRepository<ReceivedEmail>
|
||||||
|
{
|
||||||
|
public Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Generic repository interface for CRUD operations.
|
/// Generic repository interface for CRUD operations.
|
||||||
@@ -11,6 +11,8 @@ public interface IRepository<TEntity> where TEntity : class
|
|||||||
// CREATE
|
// CREATE
|
||||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// READ
|
// READ
|
||||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||||
@@ -20,6 +22,11 @@ public interface IRepository<TEntity> where TEntity : class
|
|||||||
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
|
||||||
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
// UPSERT
|
||||||
|
Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// UPDATE
|
// UPDATE
|
||||||
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
|
#if NET
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||||
|
|
||||||
@@ -11,9 +14,28 @@ public class EmailMappingProfile : Profile
|
|||||||
{
|
{
|
||||||
public EmailMappingProfile()
|
public EmailMappingProfile()
|
||||||
{
|
{
|
||||||
// SendEmailCommand -> OutgoingEmailEvent
|
// PublishEmailCommand -> Email
|
||||||
CreateMap<SendEmailCommand, OutgoingEmailEvent>()
|
// Sender is resolved via MediatR in the handler and set separately after mapping.
|
||||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(_ => Guid.NewGuid()))
|
CreateMap<PublishEmailCommand, EmailContext>()
|
||||||
.ForMember(dest => dest.QueuedAt, opt => opt.MapFrom(_ => DateTime.Now));
|
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||||
|
|
||||||
|
// EmailAccountDto -> EmailAccount
|
||||||
|
CreateMap<EmailAccount, EmailAccountDto>();
|
||||||
|
CreateMap<EmailAccountModificationDto, EmailAccount>();
|
||||||
|
|
||||||
|
// ReceivedEmailDto <-> ReceivedEmail
|
||||||
|
CreateMap<ReceivedEmailDto, ReceivedEmail>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Account, opt => opt.Ignore());
|
||||||
|
CreateMap<ReceivedEmail, ReceivedEmailDto>();
|
||||||
|
|
||||||
|
// EmailAttachmentDto <-> EmailAttachment
|
||||||
|
CreateMap<EmailAttachmentDto, EmailAttachment>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Email, opt => opt.Ignore());
|
||||||
|
CreateMap<EmailAttachment, EmailAttachmentDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Options;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Wrapper options class that holds a list of <see cref="Dto.EmailAccount"/> 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<EmailAccountModificationDto> Accounts { get; init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// How often the IMAP sync worker polls for new emails, in seconds.
|
||||||
|
/// Defaults to 300 seconds (5 minutes).
|
||||||
|
/// </summary>
|
||||||
|
public int SyncIntervalSeconds { get; init; } = 300;
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||||
|
|
||||||
|
public enum Modification
|
||||||
|
{
|
||||||
|
Upsert
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Reflection;
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Options;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Reflection;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application;
|
namespace DigitalData.MessagingService.Application;
|
||||||
|
|
||||||
@@ -35,6 +37,10 @@ public static class DependencyInjection
|
|||||||
// FluentValidation - Register all validators
|
// FluentValidation - Register all validators
|
||||||
services.AddValidatorsFromAssembly(assembly);
|
services.AddValidatorsFromAssembly(assembly);
|
||||||
|
|
||||||
|
// Register EmailAccounts configuration (IOptions<EmailAccountsOptions>)
|
||||||
|
services.Configure<EmailAccountsOptions>(configuration.GetSection(EmailAccountsOptions.SectionName));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -1,26 +1,23 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||||
<PackageReference Include="MediatR" Version="14.2.0" />
|
<PackageReference Include="MediatR" Version="14.2.0" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||||
</ItemGroup>
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Common\Dtos\" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.Common.ValueObjects;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using MediatR;
|
||||||
|
using System.Security.Principal;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
public record EmailAccountModificationCommand : IRequest<(EmailAccount, bool)>
|
||||||
|
{
|
||||||
|
public required EmailAccountModificationDto ModifiedAccount { get; init; }
|
||||||
|
|
||||||
|
public required Modification Modification { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Repo"></param>
|
||||||
|
public class EmailAccountModificationCommandHandler(IRepository<EmailAccount> Repo) : IRequestHandler<EmailAccountModificationCommand, (EmailAccount, bool)>
|
||||||
|
{
|
||||||
|
public async Task<(EmailAccount, bool)> Handle(EmailAccountModificationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return request.Modification switch
|
||||||
|
{
|
||||||
|
Modification.Upsert => await Repo.UpsertAsync(a => a.Username == request.ModifiedAccount.Username, request.ModifiedAccount, cancellationToken),
|
||||||
|
_ => throw new NotSupportedException($"Modification type '{request.Modification}' is not supported in email account modification."),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
#if NET
|
||||||
|
using MediatR;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
|
||||||
|
public record GetEmailAccountQuery : IRequest<IEnumerable<EmailAccountDto>>
|
||||||
|
{
|
||||||
|
public int? Id { get; init; }
|
||||||
|
|
||||||
|
public string? Username { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handles queries for retrieving email accounts.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Repo"></param>
|
||||||
|
/// <param name="Mapper"></param>
|
||||||
|
public class GetEmailAccountQueryHandler(IRepository<EmailAccount> Repo, IMapper Mapper) : IRequestHandler<GetEmailAccountQuery, IEnumerable<EmailAccountDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<EmailAccountDto>> Handle(GetEmailAccountQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = request.Id is null && request.Username is null
|
||||||
|
? await Repo.GetAllAsync(cancellationToken)
|
||||||
|
: await Repo.FindAsync(request.Id is int id ? x => x.Id == id : x => x.Username == request.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Any())
|
||||||
|
return Mapper.Map<IEnumerable<EmailAccountDto>>(accounts);
|
||||||
|
else
|
||||||
|
throw new NotFoundException($"No email account found for the given criteria ({(request.Id is not null ? $"Id: {request.Id}" : $"Username: {request.Username}")}).");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailAccounts.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for <see cref="GetEmailAccountQuery"/>.
|
||||||
|
/// Either <see cref="GetEmailAccountQuery.Id"/> or <see cref="GetEmailAccountQuery.Username"/> must be provided, but not both.
|
||||||
|
/// </summary>
|
||||||
|
public class GetSenderQueryValidator : AbstractValidator<GetEmailAccountQuery>
|
||||||
|
{
|
||||||
|
public GetSenderQueryValidator()
|
||||||
|
{
|
||||||
|
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.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to mark a single IMAP message as seen (read).
|
||||||
|
/// </summary>
|
||||||
|
public record MarkEmailAsSeenCommand : IRequest
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// UID of the message to mark as seen.
|
||||||
|
/// </summary>
|
||||||
|
public required long Uid { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mailbox folder the message resides in (default: "INBOX").
|
||||||
|
/// </summary>
|
||||||
|
public string Folder { get; init; } = "INBOX";
|
||||||
|
}
|
||||||
|
|
||||||
|
public class MarkEmailAsSeenCommandHandler(IImapEmailService ImapService, ILogger<MarkEmailAsSeenCommandHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<MarkEmailAsSeenCommand>
|
||||||
|
{
|
||||||
|
public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||||
|
|
||||||
|
var account = accounts.FirstOrDefault()
|
||||||
|
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||||
|
throw new BadRequestException($"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Query to fetch emails from an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
public record ReadEmailQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the email account to use.
|
||||||
|
/// </summary>
|
||||||
|
public required GetEmailAccountQuery Account { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Mail query used to filter and limit the emails retrieved.
|
||||||
|
/// </summary>
|
||||||
|
public MailSearchFilter Mail { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo) : IRequestHandler<ReadEmailQuery, IEnumerable<ReceivedEmailDto>>
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<ReceivedEmailDto>> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var accounts = await EmailAccountRepo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (accounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||||
|
|
||||||
|
var account = accounts.FirstOrDefault()
|
||||||
|
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
|
||||||
|
return Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="DateFilter"/> value object.
|
||||||
|
/// </summary>
|
||||||
|
public class DateFilterValidator : AbstractValidator<DateFilter>
|
||||||
|
{
|
||||||
|
public DateFilterValidator()
|
||||||
|
{
|
||||||
|
// At least one field must be provided
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(d => d.After is not null || d.Before is not null)
|
||||||
|
.WithName("Date")
|
||||||
|
.WithMessage("DateFilter must specify at least one of: After or Before.");
|
||||||
|
|
||||||
|
// Dates must not be in the future
|
||||||
|
RuleFor(x => x.After)
|
||||||
|
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||||
|
.WithMessage("Date.After must not be in the future.")
|
||||||
|
.When(x => x.After is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Before)
|
||||||
|
.LessThanOrEqualTo(DateTime.UtcNow)
|
||||||
|
.WithMessage("Date.Before must not be in the future.")
|
||||||
|
.When(x => x.Before is not null);
|
||||||
|
|
||||||
|
// Range coherence: After must be earlier than Before
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(d => d.After!.Value < d.Before!.Value)
|
||||||
|
.WithName("Date.Range")
|
||||||
|
.WithMessage("Date.After must be earlier than Date.Before.")
|
||||||
|
.When(x => x.After is not null && x.Before is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
|
||||||
|
/// </summary>
|
||||||
|
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
|
||||||
|
{
|
||||||
|
public FetchEmailsQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Account)
|
||||||
|
.NotNull()
|
||||||
|
.WithMessage("Account query must not be null.");
|
||||||
|
|
||||||
|
RuleFor(x => x.Mail)
|
||||||
|
.NotNull()
|
||||||
|
.WithMessage("Mail search filter must not be null.")
|
||||||
|
.SetValidator(new MailSearchFilterValidator());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="MailSearchFilter"/> instance before it is dispatched to the IMAP service.
|
||||||
|
/// </summary>
|
||||||
|
public class MailSearchFilterValidator : AbstractValidator<MailSearchFilter>
|
||||||
|
{
|
||||||
|
public MailSearchFilterValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Folder)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Folder must not be empty.")
|
||||||
|
.MaximumLength(255)
|
||||||
|
.WithMessage("Folder name must not exceed 255 characters.");
|
||||||
|
|
||||||
|
RuleFor(x => x.MaxCount)
|
||||||
|
.GreaterThanOrEqualTo(0)
|
||||||
|
.WithMessage("MaxCount must be 0 (unlimited) or a positive number.");
|
||||||
|
|
||||||
|
RuleFor(x => x.SubjectContains)
|
||||||
|
.MaximumLength(500)
|
||||||
|
.WithMessage("SubjectContains must not exceed 500 characters.")
|
||||||
|
.When(x => x.SubjectContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.SenderContains)
|
||||||
|
.MaximumLength(320)
|
||||||
|
.WithMessage("SenderContains must not exceed 320 characters.")
|
||||||
|
.When(x => x.SenderContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.RecipientContains)
|
||||||
|
.MaximumLength(320)
|
||||||
|
.WithMessage("RecipientContains must not exceed 320 characters.")
|
||||||
|
.When(x => x.RecipientContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.BodyContains)
|
||||||
|
.MaximumLength(1000)
|
||||||
|
.WithMessage("BodyContains must not exceed 1000 characters.")
|
||||||
|
.When(x => x.BodyContains is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Uid)
|
||||||
|
.SetValidator(new UidFilterValidator()!)
|
||||||
|
.When(x => x.Uid is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Date)
|
||||||
|
.SetValidator(new DateFilterValidator()!)
|
||||||
|
.When(x => x.Date is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validates a <see cref="UidFilter"/> value object.
|
||||||
|
/// </summary>
|
||||||
|
public class UidFilterValidator : AbstractValidator<UidFilter>
|
||||||
|
{
|
||||||
|
public UidFilterValidator()
|
||||||
|
{
|
||||||
|
// Absolute and range are mutually exclusive
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Absolute is null || (u.Min is null && u.Max is null))
|
||||||
|
.WithName("Uid.Absolute")
|
||||||
|
.WithMessage("Uid.Absolute cannot be combined with Uid.Min or Uid.Max. Use either an exact UID or a range.");
|
||||||
|
|
||||||
|
// At least one field must be provided
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Absolute is not null || u.Min is not null || u.Max is not null)
|
||||||
|
.WithName("Uid")
|
||||||
|
.WithMessage("UidFilter must specify at least one of: Absolute, Min, or Max.");
|
||||||
|
|
||||||
|
// All UID values must be positive
|
||||||
|
RuleFor(x => x.Absolute)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Absolute must be a positive number.")
|
||||||
|
.When(x => x.Absolute is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Min)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Min must be a positive number.")
|
||||||
|
.When(x => x.Min is not null);
|
||||||
|
|
||||||
|
RuleFor(x => x.Max)
|
||||||
|
.GreaterThan(0)
|
||||||
|
.WithMessage("Uid.Max must be a positive number.")
|
||||||
|
.When(x => x.Max is not null);
|
||||||
|
|
||||||
|
// Range coherence: Min must be less than or equal to Max
|
||||||
|
RuleFor(x => x)
|
||||||
|
.Must(u => u.Min!.Value <= u.Max!.Value)
|
||||||
|
.WithName("Uid.Range")
|
||||||
|
.WithMessage("Uid.Min must be less than or equal to Uid.Max.")
|
||||||
|
.When(x => x.Min is not null && x.Max is not null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Command to send an email (enqueue to RabbitMQ)
|
||||||
|
/// </summary>
|
||||||
|
public record PublishEmailCommand : IRequest<Guid>
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Sender { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient email addresses
|
||||||
|
/// </summary>
|
||||||
|
public required IEnumerable<string> Recipients { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject
|
||||||
|
/// </summary>
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email body (HTML or plain text)
|
||||||
|
/// </summary>
|
||||||
|
public required string Body { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is HTML email (default: true)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHtml { get; init; } = true;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a new command instance with the supplied attachments.
|
||||||
|
/// Called by the controller after resolving uploaded files.
|
||||||
|
/// </summary>
|
||||||
|
public PublishEmailCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||||
|
=> this with { Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Handler for PublishEmailCommand
|
||||||
|
/// Resolves the sender account via MediatR, maps to SendingEmailEvent and enqueues to RabbitMQ
|
||||||
|
/// </summary>
|
||||||
|
public class PublishEmailCommandHandler(IRepository<EmailAccount> Repo, ISendingEmailPublisher Publisher, IMapper Mapper, ILogger<PublishEmailCommandHandler> Logger) : IRequestHandler<PublishEmailCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(PublishEmailCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var senderAccounts = await Repo.FindAsync(request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
if (senderAccounts.Count() > 1)
|
||||||
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
|
||||||
|
|
||||||
|
var senderAccount = senderAccounts.FirstOrDefault()
|
||||||
|
?? 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
|
||||||
|
var sendingEmailEvent = new SendingEmailEvent()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = email,
|
||||||
|
QueuedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||||
|
return sendingEmailEvent.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
|
||||||
using MediatR;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Command to send an email (enqueue to RabbitMQ)
|
|
||||||
/// </summary>
|
|
||||||
public record SendEmailCommand : IRequest<OutgoingEmailEvent>
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Recipient email address
|
|
||||||
/// </summary>
|
|
||||||
public required string Recipient { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email subject
|
|
||||||
/// </summary>
|
|
||||||
public required string Subject { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email body (HTML or plain text)
|
|
||||||
/// </summary>
|
|
||||||
public required string Body { get; init; }
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Is HTML email (default: true)
|
|
||||||
/// </summary>
|
|
||||||
public bool IsHtml { get; init; } = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Handler for SendEmailCommand
|
|
||||||
/// Creates EmailOutbox entity via AutoMapper and enqueues to RabbitMQ
|
|
||||||
/// </summary>
|
|
||||||
public class SendEmailCommandHandler(IOutgoingEmailPublisher Publisher, IMapper Mapper) : IRequestHandler<SendEmailCommand, OutgoingEmailEvent>
|
|
||||||
{
|
|
||||||
public async Task<OutgoingEmailEvent> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var outgoingEmailEvent = Mapper.Map<OutgoingEmailEvent>(request);
|
|
||||||
|
|
||||||
// Enqueue to RabbitMQ
|
|
||||||
await Publisher.EnqueueAsync(outgoingEmailEvent, cancellationToken);
|
|
||||||
return outgoingEmailEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +1,26 @@
|
|||||||
|
#if NET
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
using FluentValidation;
|
using FluentValidation;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validator for SendEmailCommand
|
/// Validator for PublishEmailCommand
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
|
||||||
{
|
{
|
||||||
public SendEmailCommandValidator()
|
public PublishEmailCommandValidator()
|
||||||
{
|
{
|
||||||
RuleFor(x => x.Recipient)
|
RuleFor(x => x.Recipients)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.WithMessage("Recipient is required")
|
.WithMessage("Recipients are required")
|
||||||
.MaximumLength(200)
|
.Must(x => x.Any())
|
||||||
.WithMessage("Recipient must not exceed 200 characters")
|
.WithMessage("At least one recipient is required")
|
||||||
|
.ForEach(recipient => recipient
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Recipient email must not be empty")
|
||||||
.EmailAddress()
|
.EmailAddress()
|
||||||
.WithMessage("Recipient must be a valid email address");
|
.WithMessage("Invalid email address format"));
|
||||||
|
|
||||||
RuleFor(x => x.Subject)
|
RuleFor(x => x.Subject)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
@@ -29,3 +33,4 @@ public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
|||||||
.WithMessage("Body is required");
|
.WithMessage("Body is required");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
#endif
|
||||||
@@ -11,4 +11,8 @@
|
|||||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
|
||||||
|
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// DTO for a single email account configuration.
|
||||||
|
/// </summary>
|
||||||
|
[Table("EMAIL_ACCOUNT")]
|
||||||
|
public class EmailAccount
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Logical name to identify this account (e.g. "default", "support").
|
||||||
|
/// </summary>
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "int")]
|
||||||
|
public int Id { get; set; }
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("USERNAME", TypeName = "nvarchar(256)")]
|
||||||
|
#if NET
|
||||||
|
public required string Username { get; set; }
|
||||||
|
#else
|
||||||
|
public string Username { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("PASSWORD", TypeName = "nvarchar(512)")]
|
||||||
|
#if NET
|
||||||
|
public required string Password { get; set; }
|
||||||
|
#else
|
||||||
|
public string Password { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("SMTP_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
#if NET
|
||||||
|
public required string SmtpServer { get; set; }
|
||||||
|
#else
|
||||||
|
public string SmtpServer { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[Column("SMTP_PORT", TypeName = "int")]
|
||||||
|
public int SmtpPort { get; set; }
|
||||||
|
|
||||||
|
[Column("SMTP_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool SmtpUseSsl { get; set; }
|
||||||
|
|
||||||
|
[Column("USE_OAUTH2", TypeName = "bit")]
|
||||||
|
public bool UseOAuth2 { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server hostname (e.g. "imap.example.com").
|
||||||
|
/// Leave empty when this account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("IMAP_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
public string? ImapServer { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP server port (993 for SSL, 143 for plain/STARTTLS).
|
||||||
|
/// </summary>
|
||||||
|
[Column("IMAP_PORT", TypeName = "int")]
|
||||||
|
public int ImapPort { get; set; } = 993;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the IMAP server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("IMAP_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool ImapUseSsl { get; set; } = true;
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents a single email attachment.
|
||||||
|
/// </summary>
|
||||||
|
[Table("EMAIL_ATTACHMENT")]
|
||||||
|
public sealed class EmailAttachment
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Id { get; init; }
|
||||||
|
#else
|
||||||
|
public long Id { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Display name of the attachment (e.g. "invoice.pdf").
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(260)]
|
||||||
|
[Column("FILE_NAME", TypeName = "nvarchar(260)")]
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public string FileName { get; set; } = null!;
|
||||||
|
#else
|
||||||
|
public required string FileName { get; init; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Raw content of the attachment.
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[Column("CONTENT", TypeName = "varbinary(max)")]
|
||||||
|
#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>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("CONTENT_TYPE", TypeName = "nvarchar(256)")]
|
||||||
|
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>
|
||||||
|
[Column("IS_INLINE", TypeName = "bit")]
|
||||||
|
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>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("CONTENT_ID", TypeName = "nvarchar(512)")]
|
||||||
|
public string? ContentId { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(Email))]
|
||||||
|
[Column("EMAIL_ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long EmailId { get; init; }
|
||||||
|
#else
|
||||||
|
public long EmailId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public ReceivedEmail? Email { get; init; }
|
||||||
|
#else
|
||||||
|
public ReceivedEmail? Email { get; set; }
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Represents an email message received via IMAP.
|
||||||
|
/// </summary>
|
||||||
|
[Table("RECEIVED_EMAIL")]
|
||||||
|
public sealed record ReceivedEmail
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
[DatabaseGenerated(DatabaseGeneratedOption.Identity)]
|
||||||
|
[Column("ID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Id { get; init; }
|
||||||
|
#else
|
||||||
|
public long Id { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unique identifier of the message on the IMAP server (UID).
|
||||||
|
/// </summary>
|
||||||
|
[Column("UID", TypeName = "bigint")]
|
||||||
|
#if NET
|
||||||
|
public long Uid { get; init; }
|
||||||
|
#else
|
||||||
|
public long Uid { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sender address (From header).
|
||||||
|
/// </summary>
|
||||||
|
[Required]
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("FROM", TypeName = "nvarchar(512)")]
|
||||||
|
#if NET
|
||||||
|
public string From { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string From { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient addresses (To header).
|
||||||
|
/// </summary>
|
||||||
|
[Column("TO", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public List<string> To { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public List<string> To { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// CC addresses.
|
||||||
|
/// </summary>
|
||||||
|
[Column("CC", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public List<string> Cc { get; init; } = [];
|
||||||
|
#else
|
||||||
|
public List<string> Cc { get; set; } = [];
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(998)]
|
||||||
|
[Column("SUBJECT", TypeName = "nvarchar(998)")]
|
||||||
|
#if NET
|
||||||
|
public string Subject { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Plain-text body (may be empty when only HTML is present).
|
||||||
|
/// </summary>
|
||||||
|
[Column("TEXT_BODY", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public string TextBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string TextBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HTML body (may be empty when only plain-text is present).
|
||||||
|
/// </summary>
|
||||||
|
[Column("HTML_BODY", TypeName = "nvarchar(max)")]
|
||||||
|
#if NET
|
||||||
|
public string HtmlBody { get; init; } = string.Empty;
|
||||||
|
#else
|
||||||
|
public string HtmlBody { get; set; } = string.Empty;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Date/time the message was sent (Date header).
|
||||||
|
/// </summary>
|
||||||
|
[Column("DATE", TypeName = "datetime2")]
|
||||||
|
#if NET
|
||||||
|
public DateTime Date { get; init; }
|
||||||
|
#else
|
||||||
|
public DateTime Date { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether the message has been marked as seen/read on the server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("IS_SEEN", TypeName = "bit")]
|
||||||
|
#if NET
|
||||||
|
public bool IsSeen { get; init; }
|
||||||
|
#else
|
||||||
|
public bool IsSeen { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
[ForeignKey(nameof(Account))]
|
||||||
|
[Column("ACCOUNT_ID", TypeName = "int")]
|
||||||
|
#if NET
|
||||||
|
public int AccountId { get; init; }
|
||||||
|
#else
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Attachments included with this message.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public IEnumerable<EmailAttachment>? Attachments { get; init; }
|
||||||
|
#else
|
||||||
|
public IEnumerable<EmailAttachment>? Attachments { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public EmailAccount? Account { get; init; }
|
||||||
|
#else
|
||||||
|
public EmailAccount? Account { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Exceptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when a requested entity is not found.
|
||||||
|
/// </summary>
|
||||||
|
public class BadRequestException : Exception
|
||||||
|
{
|
||||||
|
public BadRequestException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BadRequestException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public BadRequestException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<LangVersion>latest</LangVersion>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -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,10 +1,16 @@
|
|||||||
|
using AutoMapper;
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Mappings;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Repositories;
|
||||||
using DigitalData.MessagingService.Infrastructure.Services;
|
using DigitalData.MessagingService.Infrastructure.Services;
|
||||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
using DigitalData.MessagingService.Publisher;
|
using DigitalData.MessagingService.Publisher;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
using Microsoft.AspNetCore.DataProtection;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Configuration;
|
using Microsoft.Extensions.Configuration;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
@@ -23,9 +29,13 @@ public static class DependencyInjection
|
|||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
{
|
{
|
||||||
// --- External Services ---
|
// --- External Services ---
|
||||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
// Email Service - SMTP outbound (Limilabs Mail.dll)
|
||||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
||||||
|
|
||||||
|
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
||||||
|
// Fresh connection per call — stateless and thread-safe.
|
||||||
|
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
|
||||||
|
|
||||||
// PDF Processing Service (using DevExpress.Pdf)
|
// PDF Processing Service (using DevExpress.Pdf)
|
||||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||||
|
|
||||||
@@ -33,7 +43,7 @@ public static class DependencyInjection
|
|||||||
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
||||||
|
|
||||||
// --- Email Queue (RabbitMQ) ---
|
// --- Email Queue (RabbitMQ) ---
|
||||||
services.AddSingleton<OutgoingEmailConsumer>();
|
services.AddSingleton<SendingEmailConsumerPool>();
|
||||||
services.AddMessagingServicePublisher();
|
services.AddMessagingServicePublisher();
|
||||||
|
|
||||||
// --- RabbitMQ Configuration ---
|
// --- RabbitMQ Configuration ---
|
||||||
@@ -46,7 +56,22 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Register Background Workers
|
// Register Background Workers
|
||||||
services.AddHostedService<AsyncInitWorker>();
|
services.AddHostedService<AsyncInitWorker>();
|
||||||
|
services.AddHostedService<EmailSyncWorker>();
|
||||||
|
|
||||||
|
services.AddMemoryCache();
|
||||||
|
|
||||||
|
// --- Database (InMemory) ---
|
||||||
|
services.AddDbContext<MessagingServiceDbContext>(options =>
|
||||||
|
options.UseInMemoryDatabase("MessagingServiceDb"));
|
||||||
|
|
||||||
|
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||||
|
|
||||||
|
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
|
||||||
|
|
||||||
|
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
||||||
|
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile that registers self-mappings (T -> T) for all domain entities.
|
||||||
|
/// This allows AutoMapper to be used uniformly in the generic repository
|
||||||
|
/// regardless of whether TDto is a DTO or the entity type itself.
|
||||||
|
/// </summary>
|
||||||
|
public class EntitySelfMappingProfile : Profile
|
||||||
|
{
|
||||||
|
public EntitySelfMappingProfile()
|
||||||
|
{
|
||||||
|
CreateMap<EmailAccount, EmailAccount>();
|
||||||
|
CreateMap<ReceivedEmail, ReceivedEmail>();
|
||||||
|
CreateMap<EmailAttachment, EmailAttachment>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,11 +1,38 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Entity Framework Core DbContext for MessagingService.
|
/// Entity Framework Core DbContext for MessagingService.
|
||||||
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
||||||
{
|
{
|
||||||
|
public DbSet<EmailAccount> EmailAccounts => Set<EmailAccount>();
|
||||||
|
public DbSet<ReceivedEmail> ReceivedEmails => Set<ReceivedEmail>();
|
||||||
|
public DbSet<EmailAttachment> EmailAttachments => Set<EmailAttachment>();
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
base.OnModelCreating(modelBuilder);
|
||||||
|
|
||||||
|
modelBuilder.Entity<EmailAccount>(entity =>
|
||||||
|
{
|
||||||
|
entity.Property(e => e.Username)
|
||||||
|
.UseCollation("SQL_Latin1_General_CP1_CI_AS");
|
||||||
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity<ReceivedEmail>(entity =>
|
||||||
|
{
|
||||||
|
entity.HasMany(e => e.Attachments)
|
||||||
|
.WithOne(a => a.Email)
|
||||||
|
.HasForeignKey(a => a.EmailId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
entity.HasOne(e => e.Account)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(e => e.AccountId)
|
||||||
|
.OnDelete(DeleteBehavior.Restrict);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,71 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using RabbitMQ.Client.Events;
|
using RabbitMQ.Client.Events;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
/// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
|
||||||
/// Provides message persistence, scalability, and reliability.
|
/// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
|
||||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
/// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
|
||||||
/// </summary>
|
/// </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<IChannel>> _lazyChannel;
|
||||||
|
|
||||||
private readonly Lazy<Task> _lazyInit;
|
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;
|
_logger = logger;
|
||||||
_config = config.Value;
|
_queueName = queueName;
|
||||||
|
|
||||||
_lazyChannel = new(CnnFactory.CreateChannelAsync);
|
_lazyChannel = new(cnnFactory.CreateChannelAsync);
|
||||||
_lazyInit = new(async () => {
|
_lazyInit = new(async () =>
|
||||||
|
{
|
||||||
var channel = await _lazyChannel.Value;
|
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);
|
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||||
|
|
||||||
consumer.ReceivedAsync += async (sender, args) =>
|
consumer.ReceivedAsync += async (sender, args) =>
|
||||||
{
|
{
|
||||||
OutgoingEmailEvent? oMailEvent = null;
|
SendingEmailEvent? oMailEvent = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
||||||
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
|
oMailEvent = JsonSerializer.Deserialize<SendingEmailEvent>(json);
|
||||||
|
|
||||||
if (oMailEvent is not null)
|
if (oMailEvent is not null)
|
||||||
{
|
{
|
||||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||||
await EmailService.SendEmailAsync(
|
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
|
||||||
oMailEvent.Recipient,
|
|
||||||
oMailEvent.Subject,
|
|
||||||
oMailEvent.Body,
|
|
||||||
isHtml: oMailEvent.IsHtml);
|
|
||||||
|
|
||||||
// Acknowledge message after successful processing
|
// Acknowledge message after successful processing
|
||||||
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
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
|
else
|
||||||
{
|
{
|
||||||
@@ -64,11 +75,11 @@ public sealed class OutgoingEmailConsumer : IAsyncDisposable
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
// TODO: Error Reporting Strategy
|
||||||
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
// 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)
|
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
||||||
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
// - 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)
|
// Start consuming messages (event-driven, non-blocking)
|
||||||
await channel.BasicConsumeAsync(
|
await channel.BasicConsumeAsync(
|
||||||
queue: _config.QueueName,
|
queue: _queueName,
|
||||||
autoAck: false,
|
autoAck: false,
|
||||||
consumer: consumer,
|
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>
|
/// <summary>
|
||||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
/// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
|
||||||
/// Start event-driven consumer that processes messages as they arrive
|
/// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
|
||||||
/// Called lazily on first use via EnsureInitializedAsync.
|
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task InitAsync()
|
public async Task InitAsync()
|
||||||
{
|
{
|
||||||
if (_lazyInit.IsValueCreated)
|
if (_lazyInit.IsValueCreated)
|
||||||
_logger?.LogWarning("OutgoingEmailConsumer already initialized. InitAsync() called multiple times.");
|
_logger?.LogWarning("SendingEmailConsumer already initialized. InitAsync() called multiple times.");
|
||||||
|
|
||||||
await _lazyInit.Value;
|
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()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public class ReceivedEmailRepository(MessagingServiceDbContext Context, IMapper Mapper) : Repository<ReceivedEmail>(Context, Mapper), IReceivedEmailRepository
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = DbSet.AsNoTracking();
|
||||||
|
|
||||||
|
// ── Account filter ─────────────────────────────────────────────────────
|
||||||
|
if (accountQuery is not null)
|
||||||
|
query = query.Where(x => x.AccountId == accountQuery.Id);
|
||||||
|
|
||||||
|
// ── Flag filters ───────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.UnseenOnly)
|
||||||
|
query = query.Where(x => !x.IsSeen);
|
||||||
|
|
||||||
|
// ── Text filters ───────────────────────────────────────────────────────
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SubjectContains))
|
||||||
|
query = query.Where(x => x.Subject.Contains(mailSearchFilter.SubjectContains));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SenderContains))
|
||||||
|
query = query.Where(x => x.From.Contains(mailSearchFilter.SenderContains));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.BodyContains))
|
||||||
|
query = query.Where(x => x.TextBody.Contains(mailSearchFilter.BodyContains)
|
||||||
|
|| x.HtmlBody.Contains(mailSearchFilter.BodyContains));
|
||||||
|
|
||||||
|
// ── UID filter ─────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.Uid is { } uid)
|
||||||
|
{
|
||||||
|
if (uid.Absolute.HasValue)
|
||||||
|
query = query.Where(x => x.Uid == uid.Absolute.Value);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (uid.Min.HasValue)
|
||||||
|
query = query.Where(x => x.Uid >= uid.Min.Value);
|
||||||
|
if (uid.Max.HasValue)
|
||||||
|
query = query.Where(x => x.Uid <= uid.Max.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date filter ────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.Date is { } date)
|
||||||
|
{
|
||||||
|
if (date.After.HasValue)
|
||||||
|
query = query.Where(x => x.Date >= date.After.Value);
|
||||||
|
if (date.Before.HasValue)
|
||||||
|
query = query.Where(x => x.Date <= date.Before.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.WithAttachments)
|
||||||
|
query = query.Include(x => x.Attachments);
|
||||||
|
|
||||||
|
// ── Sort ───────────────────────────────────────────────────────────────
|
||||||
|
query = mailSearchFilter.SortOrder == MailSortOrder.OldestFirst
|
||||||
|
? query.OrderBy(x => x.Date)
|
||||||
|
: query.OrderByDescending(x => x.Date);
|
||||||
|
|
||||||
|
// ── Limit ──────────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.MaxCount.HasValue)
|
||||||
|
query = query.Take(mailSearchFilter.MaxCount.Value);
|
||||||
|
|
||||||
|
// ── RecipientContains: To/Cc are IEnumerable<string> (nvarchar(max)) ──
|
||||||
|
// EF Core cannot translate collection predicates on these columns to SQL.
|
||||||
|
// Materialization is deferred until after other DB-side filters narrow the set.
|
||||||
|
var results = await query.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.RecipientContains))
|
||||||
|
results = [.. results
|
||||||
|
.Where(x => x.To.Any(t => t.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|| x.Cc.Any(c => c.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase)))];
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
using System.Linq.Expressions;
|
using System.Linq.Expressions;
|
||||||
using AutoMapper;
|
using AutoMapper;
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
@@ -13,28 +13,36 @@ namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
||||||
{
|
{
|
||||||
private readonly DbSet<TEntity> _dbSet = Context.Set<TEntity>();
|
protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
|
||||||
|
|
||||||
// --- CREATE ---
|
// --- CREATE ---
|
||||||
|
|
||||||
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = Mapper.Map<TEntity>(dto);
|
var entity = Mapper.Map<TEntity>(dto);
|
||||||
await _dbSet.AddAsync(entity, cancellationToken);
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
||||||
|
await DbSet.AddRangeAsync(entities, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return entities;
|
||||||
|
}
|
||||||
|
|
||||||
// --- READ ---
|
// --- READ ---
|
||||||
|
|
||||||
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FindAsync([id], cancellationToken);
|
return await DbSet.FindAsync([id], cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.ToListAsync(cancellationToken);
|
return await DbSet.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> FindAsync(
|
public async Task<IEnumerable<TEntity>> FindAsync(
|
||||||
@@ -43,7 +51,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
int? take = null,
|
int? take = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = _dbSet.Where(predicate);
|
var query = DbSet.Where(predicate);
|
||||||
|
|
||||||
if (skip.HasValue)
|
if (skip.HasValue)
|
||||||
query = query.Skip(skip.Value);
|
query = query.Skip(skip.Value);
|
||||||
@@ -58,14 +66,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TEntity?> FindSingleAsync(
|
public async Task<TEntity?> FindSingleAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CountAsync(
|
public async Task<int> CountAsync(
|
||||||
@@ -73,15 +81,68 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return predicate == null
|
return predicate == null
|
||||||
? await _dbSet.CountAsync(cancellationToken)
|
? await DbSet.CountAsync(cancellationToken)
|
||||||
: await _dbSet.CountAsync(predicate, cancellationToken);
|
: await DbSet.CountAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> AnyAsync(
|
public async Task<bool> AnyAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.AnyAsync(predicate, cancellationToken);
|
return await DbSet.AnyAsync(predicate, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- UPSERT ---
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upsert: if no record matches the predicate, creates a new entity;
|
||||||
|
/// if one or more match, updates the FIRST match.
|
||||||
|
/// Returns the entity and a flag indicating whether it was created (true) or updated (false).
|
||||||
|
/// Auto-saves changes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(
|
||||||
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
|
TDto dto,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Upsert (single-safe): if no record matches the predicate, creates a new entity;
|
||||||
|
/// if exactly one matches, updates it. Throws InvalidOperationException if 2+ match.
|
||||||
|
/// Auto-saves changes.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(
|
||||||
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
|
TDto dto,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
|
if (entity is null)
|
||||||
|
{
|
||||||
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
Mapper.Map(dto, entity);
|
||||||
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
|
return (entity, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UPDATE ---
|
// --- UPDATE ---
|
||||||
@@ -96,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
Mapper.Map(dto, entity);
|
Mapper.Map(dto, entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
@@ -112,13 +173,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
|
entities.ForEach(entity => Mapper.Map(dto, entity));
|
||||||
foreach (var entity in entities)
|
|
||||||
{
|
|
||||||
Mapper.Map(dto, entity);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
}
|
}
|
||||||
@@ -134,9 +190,9 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
_dbSet.Remove(entity);
|
DbSet.Remove(entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,9 +205,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
|
DbSet.RemoveRange(entities);
|
||||||
_dbSet.RemoveRange(entities);
|
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,19 +1,18 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
|
|
||||||
/// <summary>
|
/// <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.
|
/// 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.
|
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class AsyncInitWorker(OutgoingEmailConsumer EmailConsumer) : BackgroundService
|
public class AsyncInitWorker(SendingEmailConsumerPool ConsumerPool) : BackgroundService
|
||||||
{
|
{
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await EmailConsumer.InitAsync();
|
await ConsumerPool.InitAsync();
|
||||||
|
|
||||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Options;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
|
|
||||||
|
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly string DefaultFolder = "INBOX";
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
await UpsertSeedEmailAccount(stoppingToken);
|
||||||
|
|
||||||
|
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
||||||
|
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
using var scope = Provider.CreateAsyncScope();
|
||||||
|
|
||||||
|
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||||
|
|
||||||
|
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
|
||||||
|
|
||||||
|
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
||||||
|
if (account.ImapServer is not null)
|
||||||
|
{
|
||||||
|
Logger.LogDebug("Email synchronization has started for account {username} in folder {folder}.", account.Username, DefaultFolder);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var res = await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken);
|
||||||
|
Logger.LogDebug("Email synchronization has completed for account {username} in folder {folder}. Processed: {processedCount}, Failed: {failedCount}", account.Username, DefaultFolder, res.ProcessedCount, res.FailedCount);
|
||||||
|
}
|
||||||
|
catch(Exception ex)
|
||||||
|
{
|
||||||
|
// Log the exception or handle it as needed
|
||||||
|
Logger.LogError(ex, "Error syncing emails for account {username}", account.Username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
using var scope = Provider.CreateAsyncScope();
|
||||||
|
|
||||||
|
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||||
|
|
||||||
|
// init seed email accounts if not exist
|
||||||
|
foreach (var account in Options.Value.Accounts)
|
||||||
|
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
|
||||||
|
public static class CacheExtensions
|
||||||
|
{
|
||||||
|
private readonly static string ImapCacheKeyPrefix = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
private static string CreateImapLastSyncDateCacheKey(int accountId, string folder)
|
||||||
|
{
|
||||||
|
return $"{ImapCacheKeyPrefix}_{accountId}_{folder}_LastImapSyncDate";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime? GetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder)
|
||||||
|
{
|
||||||
|
return cache.Get<DateTime?>(CreateImapLastSyncDateCacheKey(accountId, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder, DateTime date)
|
||||||
|
{
|
||||||
|
var key = CreateImapLastSyncDateCacheKey(accountId, folder);
|
||||||
|
cache.Set(key, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using Limilabs.Client.IMAP;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
|
||||||
|
public static class ImapExtensions
|
||||||
|
{
|
||||||
|
public static async Task CloseSafelyAsync(this Imap imap)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (imap.Connected)
|
||||||
|
await imap.CloseAsync();
|
||||||
|
}
|
||||||
|
catch { /* Ignore disconnect errors */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,140 @@
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
using Limilabs.Client.SMTP;
|
using Limilabs.Client.SMTP;
|
||||||
using Limilabs.Mail;
|
using Limilabs.Mail;
|
||||||
using Limilabs.Mail.Headers;
|
using Limilabs.Mail.Headers;
|
||||||
using Microsoft.Extensions.Options;
|
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
||||||
/// Commercial-grade library with superior Exchange support.
|
/// 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="EmailAccount.Name"/> equals <c>"default"</c>,
|
||||||
|
/// or falls back to the first account if none is named "default".
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class LimilabsEmailService(
|
public class LimilabsEmailService() : IEmailService
|
||||||
IEncryptionService encryptionService,
|
|
||||||
IOptions<EmailAccountDto> smtpConfig) : IEmailService
|
|
||||||
{
|
{
|
||||||
private readonly EmailAccountDto _smtpAccount = smtpConfig.Value;
|
|
||||||
|
|
||||||
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
||||||
static LimilabsEmailService()
|
static LimilabsEmailService()
|
||||||
{
|
{
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
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();
|
using var smtp = new Smtp();
|
||||||
|
ISendMessageResult? result = null;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await ConnectAndAuthenticateSmtpAsync(smtp);
|
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender);
|
||||||
|
|
||||||
var builder = new MailBuilder();
|
var builder = new MailBuilder();
|
||||||
builder.From.Add(new MailBox(_smtpAccount.Username));
|
builder.From.Add(new MailBox(context.Sender.Username));
|
||||||
builder.To.Add(new MailBox(to));
|
|
||||||
builder.Subject = subject;
|
|
||||||
|
|
||||||
if (isHtml)
|
foreach (var recipient in context.Recipients)
|
||||||
{
|
builder.To.Add(new MailBox(recipient));
|
||||||
builder.Html = body;
|
|
||||||
}
|
builder.Subject = context.Subject;
|
||||||
|
|
||||||
|
if (context.IsHtml)
|
||||||
|
builder.Html = context.Body;
|
||||||
else
|
else
|
||||||
{
|
builder.Text = context.Body;
|
||||||
builder.Text = body;
|
|
||||||
}
|
AddAttachments(builder, context.Attachments);
|
||||||
|
|
||||||
var mail = builder.Create();
|
var mail = builder.Create();
|
||||||
|
|
||||||
var result = smtp.SendMessage(mail);
|
result = await smtp.SendMessageAsync(mail, cancellationToken);
|
||||||
|
|
||||||
if (result.Status != SendMessageStatus.Success)
|
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 smtp.CloseAsync(cancellationToken);
|
||||||
await Task.CompletedTask; // For async consistency
|
|
||||||
}
|
}
|
||||||
catch (Limilabs.Client.ServerException ex)
|
catch (Limilabs.Client.ServerException ex)
|
||||||
{
|
{
|
||||||
DisconnectSafely(smtp);
|
await smtp.CloseSafelyAsync();
|
||||||
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
|
throw new AuthenticationFailedException($"SMTP authentication failed. Check credentials or OAuth2 configuration. {ErrorMessageBuilder(result)}", ex);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
DisconnectSafely(smtp);
|
await smtp.CloseSafelyAsync();
|
||||||
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
|
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Private Helper Methods ---
|
private static async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount)
|
||||||
|
|
||||||
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp)
|
|
||||||
{
|
{
|
||||||
if (_smtpAccount.SmtpUseSsl)
|
if (smtpAccount.SmtpUseSsl)
|
||||||
{
|
{
|
||||||
smtp.ConnectSSL(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
|
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort);
|
||||||
}
|
}
|
||||||
else
|
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.");
|
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false.");
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
var password = _smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(_smtpAccount.Password) : _smtpAccount.Password;
|
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password);
|
||||||
|
}
|
||||||
smtp.Login(_smtpAccount.Username, password);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.CompletedTask; // For async consistency
|
private static string ErrorMessageBuilder(ISendMessageResult? result = null)
|
||||||
}
|
|
||||||
|
|
||||||
private static void DisconnectSafely(Smtp smtp)
|
|
||||||
{
|
{
|
||||||
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)
|
message.AppendLine($" • {error}");
|
||||||
smtp.Close();
|
}
|
||||||
|
|
||||||
|
return message.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
catch { /* Ignore disconnect errors */ }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,189 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
using Limilabs.Client.IMAP;
|
||||||
|
using Limilabs.Mail;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Text;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP email service using Limilabs Mail.dll.
|
||||||
|
/// Opens a fresh connection per call — stateless and thread-safe.
|
||||||
|
/// </summary>
|
||||||
|
public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IRepository<ReceivedEmail> Repository) : IImapEmailService
|
||||||
|
{
|
||||||
|
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
static LimilabsImapEmailService()
|
||||||
|
{
|
||||||
|
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var imap = await OpenAsync(account, folder, cancel);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#region Find UIDs
|
||||||
|
// Server-side: only date range; all other filters are applied in-process after cache retrieval
|
||||||
|
List<ICriterion> criterions = [];
|
||||||
|
|
||||||
|
var since = GetLastImapSyncDate(account.Id, folder);
|
||||||
|
|
||||||
|
if (since is not null && since != default)
|
||||||
|
criterions.Add(Expression.SentSince(since.Value));
|
||||||
|
|
||||||
|
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
|
||||||
|
|
||||||
|
var operationStartTime = DateTime.UtcNow;
|
||||||
|
|
||||||
|
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
if (uids.Count == 0)
|
||||||
|
return new EmailSyncResult();
|
||||||
|
|
||||||
|
var emails = new List<ReceivedEmailDto>(uids.Count);
|
||||||
|
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
|
foreach (var uid in uids)
|
||||||
|
{
|
||||||
|
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
cancel.ThrowIfCancellationRequested();
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
#region Read email
|
||||||
|
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
||||||
|
var mail = new MailBuilder().CreateFromEml(eml);
|
||||||
|
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
||||||
|
|
||||||
|
var attachments = new List<EmailAttachmentDto>();
|
||||||
|
|
||||||
|
foreach (var att in mail.Attachments)
|
||||||
|
{
|
||||||
|
attachments.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = att.FileName ?? "attachment",
|
||||||
|
Content = att.Data,
|
||||||
|
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = false,
|
||||||
|
ContentId = att.ContentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var vis in mail.Visuals)
|
||||||
|
{
|
||||||
|
attachments.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = vis.FileName ?? "inline",
|
||||||
|
Content = vis.Data,
|
||||||
|
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = true,
|
||||||
|
ContentId = vis.ContentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = new ReceivedEmailDto
|
||||||
|
{
|
||||||
|
Uid = uid,
|
||||||
|
AccountId = account.Id,
|
||||||
|
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
|
||||||
|
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||||
|
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||||
|
Subject = mail.Subject ?? string.Empty,
|
||||||
|
TextBody = mail.Text ?? string.Empty,
|
||||||
|
HtmlBody = mail.Html ?? string.Empty,
|
||||||
|
Date = mail.Date ?? DateTime.MinValue,
|
||||||
|
IsSeen = flags.Contains(Flag.Seen),
|
||||||
|
Attachments = attachments,
|
||||||
|
Folder = folder
|
||||||
|
};
|
||||||
|
#endregion Read email
|
||||||
|
|
||||||
|
emails.Add(email);
|
||||||
|
|
||||||
|
SetLastImapSyncDate(account.Id, folder, operationStartTime);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
failedCount += 1;
|
||||||
|
Logger.LogWarning(ex,
|
||||||
|
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
|
||||||
|
uid, folder);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await imap.CloseAsync(cancel);
|
||||||
|
|
||||||
|
await Repository.CreateRangeAsync(emails, cancel);
|
||||||
|
|
||||||
|
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await imap.CloseSafelyAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
using var imap = await OpenAsync(account, folder, cancel);
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
|
||||||
|
await imap.CloseAsync(cancel);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
await imap.CloseSafelyAsync();
|
||||||
|
throw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
|
{
|
||||||
|
var imap = new Imap();
|
||||||
|
|
||||||
|
if (account.ImapUseSsl)
|
||||||
|
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||||
|
else
|
||||||
|
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||||
|
|
||||||
|
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||||
|
|
||||||
|
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
|
||||||
|
await imap.SelectInboxAsync(cancel);
|
||||||
|
else
|
||||||
|
await imap.SelectAsync(folder, cancel);
|
||||||
|
|
||||||
|
return imap;
|
||||||
|
}
|
||||||
|
|
||||||
|
#region IMAP Last Sync Date Cache
|
||||||
|
private readonly ConcurrentDictionary<ImapCacheKey, DateTime> _cache = new();
|
||||||
|
|
||||||
|
private record ImapCacheKey(int AccountId, string Folder);
|
||||||
|
|
||||||
|
public DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX")
|
||||||
|
{
|
||||||
|
return _cache.GetValueOrDefault(new ImapCacheKey(accountId, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetLastImapSyncDate(int accountId, string folder, DateTime date)
|
||||||
|
{
|
||||||
|
var key = new ImapCacheKey(accountId, folder);
|
||||||
|
_cache[key] = date;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Publisher;
|
namespace DigitalData.MessagingService.Publisher;
|
||||||
@@ -13,7 +12,7 @@ public static class DependencyInjection
|
|||||||
services.AddRabbitMqConnectionFactory(configure);
|
services.AddRabbitMqConnectionFactory(configure);
|
||||||
|
|
||||||
// --- Email Queue (RabbitMQ) ---
|
// --- Email Queue (RabbitMQ) ---
|
||||||
services.AddSingleton<IOutgoingEmailPublisher, OutgoingEmailPublisher>();
|
services.AddSingleton<ISendingEmailPublisher, SendingEmailPublisher>();
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Publisher.Abstraction\DigitalData.MessagingService.Publisher.Abstraction.csproj" />
|
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,8 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using RabbitMQ.Client;
|
using RabbitMQ.Client;
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Publisher;
|
namespace DigitalData.MessagingService.Publisher;
|
||||||
|
|
||||||
@@ -13,14 +14,14 @@ namespace DigitalData.MessagingService.Publisher;
|
|||||||
/// Provides message persistence, scalability, and reliability.
|
/// Provides message persistence, scalability, and reliability.
|
||||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisposable
|
public sealed class SendingEmailPublisher : ISendingEmailPublisher, IAsyncDisposable
|
||||||
{
|
{
|
||||||
private readonly RabbitMqConfiguration _config;
|
private readonly RabbitMqConfiguration _config;
|
||||||
private readonly ILogger<OutgoingEmailPublisher> _logger;
|
private readonly ILogger<SendingEmailPublisher> _logger;
|
||||||
private readonly RabbitMqConnectionFactory _cnnFactory;
|
private readonly RabbitMqConnectionFactory _cnnFactory;
|
||||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
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;
|
_config = config.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -66,9 +67,9 @@ public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisp
|
|||||||
return channel;
|
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 body = Encoding.UTF8.GetBytes(json);
|
||||||
|
|
||||||
var properties = new BasicProperties
|
var properties = new BasicProperties
|
||||||
@@ -75,5 +75,11 @@ namespace DigitalData.MessagingService.RabbitMQ
|
|||||||
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
|
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
[ApiController]
|
||||||
|
public class EmailAccountController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query"></param>
|
||||||
|
/// <param name="cancellationToken"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> GetEmailAccount([FromQuery] GetEmailAccountQuery query, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Ok(await mediator.Send(query, cancellationToken));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,115 @@
|
|||||||
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.API.Controllers;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email sending API controller.
|
||||||
|
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
||||||
|
/// </summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/[controller]")]
|
||||||
|
public class EmailController(IMediator mediator) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
public enum OnlyFilter
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
HtmlBody,
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
Uid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#region Send
|
||||||
|
/// <summary>
|
||||||
|
/// Send an email, optionally with file attachments.
|
||||||
|
/// Omit the <c>attachments</c> field for a plain send.
|
||||||
|
/// </summary>
|
||||||
|
/// <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 with the queued event ID</returns>
|
||||||
|
[HttpPost]
|
||||||
|
[Consumes("multipart/form-data")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
public async Task<IActionResult> SendEmail(
|
||||||
|
[FromForm] PublishEmailCommand command,
|
||||||
|
IFormFileCollection? attachments,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var commandWithAttachments = command.WithAttachments(
|
||||||
|
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||||
|
|
||||||
|
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||||
|
return Accepted(new { Id = eventId });
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
|
||||||
|
IFormFileCollection? files,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
if (files is null || files.Count == 0)
|
||||||
|
return [];
|
||||||
|
|
||||||
|
var result = new List<EmailAttachmentDto>(files.Count);
|
||||||
|
|
||||||
|
foreach (var file in files)
|
||||||
|
{
|
||||||
|
using var ms = new MemoryStream();
|
||||||
|
await file.CopyToAsync(ms, cancellationToken);
|
||||||
|
|
||||||
|
result.Add(new EmailAttachmentDto
|
||||||
|
{
|
||||||
|
FileName = file.FileName,
|
||||||
|
Content = ms.ToArray(),
|
||||||
|
ContentType = file.ContentType
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
#endregion Send
|
||||||
|
|
||||||
|
#region Receive
|
||||||
|
/// <summary>
|
||||||
|
/// Fetch emails from an IMAP mailbox.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
|
||||||
|
/// <param name="only"></param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
|
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var emails = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
if(!emails.Any())
|
||||||
|
return NotFound("No emails found matching the specified criteria.");
|
||||||
|
|
||||||
|
if (only == OnlyFilter.HtmlBody)
|
||||||
|
{
|
||||||
|
if (emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||||
|
return Content(htmlBody, "text/html");
|
||||||
|
else
|
||||||
|
return NotFound();
|
||||||
|
}
|
||||||
|
else if (only == OnlyFilter.Uid)
|
||||||
|
return Ok(emails.Select(e => e.Uid).ToList());
|
||||||
|
else
|
||||||
|
return Ok(emails);
|
||||||
|
}
|
||||||
|
#endregion Receive
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
using MediatR;
|
|
||||||
using Microsoft.AspNetCore.Mvc;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.API.Controllers;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// 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)
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="command">Send email command</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token</param>
|
|
||||||
/// <returns>HTTP 202 Accepted (queued for processing)</returns>
|
|
||||||
[HttpPost("send")]
|
|
||||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
|
||||||
public async Task<IActionResult> SendEmail([FromBody] SendEmailCommand command, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var outgoingEmailEvent = await mediator.Send(command, cancellationToken);
|
|
||||||
|
|
||||||
return Accepted(new
|
|
||||||
{
|
|
||||||
CommandId = outgoingEmailEvent.Id,
|
|
||||||
To = outgoingEmailEvent.Recipient,
|
|
||||||
outgoingEmailEvent.Subject,
|
|
||||||
outgoingEmailEvent.QueuedAt
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,29 +1,43 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net8.0</TargetFrameworks>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<UserSecretsId>dotnet-DigitalData.MessagingService.Service-e7ef6a9a-436f-48a1-b4f6-ec9381c8cb47</UserSecretsId>
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<PackageId>DigitalData.MessagingService.API</PackageId>
|
||||||
|
<Title></Title>
|
||||||
|
<Authors>Digital Data GmbH</Authors>
|
||||||
|
<Company>Digital Data GmbH</Company>
|
||||||
|
<Product>DigitalData.MessagingService.API</Product>
|
||||||
|
<Version>1.0.0-beta</Version>
|
||||||
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
|
<Copyright>Copyright © 2026 Digital Data GmbH. All rights reserved.</Copyright>
|
||||||
|
<DocumentationFile>bin\$(Configuration)\$(TargetFramework)\$(AssemblyName).xml</DocumentationFile>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Settings.Configuration" Version="10.0.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Serilog.Sinks.SQLite" Version="7.0.0" />
|
||||||
|
<PackageReference Include="Serilog.UI" Version="3.2.0" />
|
||||||
|
<PackageReference Include="Serilog.UI.SqliteProvider" Version="1.1.0" />
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Properties\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||||
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
|
<ProjectReference Include="..\..\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Folder Include="Infrastructure\Swagger\" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,55 +1,47 @@
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.API.Middleware;
|
namespace DigitalData.MessagingService.API.Middleware;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Global exception handling middleware
|
/// Global exception handling middleware
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class ExceptionHandlingMiddleware
|
public class ExceptionHandlingMiddleware(RequestDelegate Next, ILogger<ExceptionHandlingMiddleware> Logger)
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
||||||
{
|
{
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
||||||
};
|
};
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
/// <summary>
|
||||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
///
|
||||||
|
/// </summary>
|
||||||
public ExceptionHandlingMiddleware(
|
/// <param name="context"></param>
|
||||||
RequestDelegate next,
|
/// <returns></returns>
|
||||||
ILogger<ExceptionHandlingMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
public async Task InvokeAsync(HttpContext context)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _next(context);
|
await Next(context);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
|
||||||
await HandleExceptionAsync(context, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
|
||||||
{
|
{
|
||||||
context.Response.ContentType = "application/json";
|
context.Response.ContentType = "application/json";
|
||||||
|
|
||||||
var (statusCode, message) = exception switch
|
var (statusCode, message) = ex switch
|
||||||
{
|
{
|
||||||
NotFoundException notFoundEx =>
|
NotFoundException notFoundEx =>
|
||||||
(HttpStatusCode.NotFound, notFoundEx.Message),
|
(HttpStatusCode.NotFound, notFoundEx.Message),
|
||||||
|
|
||||||
|
BadRequestException badRequestEx =>
|
||||||
|
(HttpStatusCode.BadRequest, badRequestEx.Message),
|
||||||
|
|
||||||
AuthenticationFailedException authEx =>
|
AuthenticationFailedException authEx =>
|
||||||
(HttpStatusCode.Unauthorized, authEx.Message),
|
(HttpStatusCode.Unauthorized, authEx.Message),
|
||||||
|
|
||||||
FluentValidation.ValidationException validationEx =>
|
ValidationException validationEx =>
|
||||||
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
||||||
|
|
||||||
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
||||||
@@ -60,11 +52,11 @@ public class ExceptionHandlingMiddleware
|
|||||||
// Log the exception
|
// Log the exception
|
||||||
if (statusCode == HttpStatusCode.InternalServerError)
|
if (statusCode == HttpStatusCode.InternalServerError)
|
||||||
{
|
{
|
||||||
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
Logger.LogError(ex, "Unhandled exception: {Message}", ex.Message);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
|
Logger.LogWarning(ex, "Exception handled: {StatusCode} - {Message}",
|
||||||
statusCode, message);
|
statusCode, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +65,7 @@ public class ExceptionHandlingMiddleware
|
|||||||
StatusCode = (int)statusCode,
|
StatusCode = (int)statusCode,
|
||||||
Message = message,
|
Message = message,
|
||||||
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
||||||
? exception.Message
|
? ex.Message
|
||||||
: null,
|
: null,
|
||||||
Timestamp = DateTime.Now
|
Timestamp = DateTime.Now
|
||||||
};
|
};
|
||||||
@@ -82,8 +74,9 @@ public class ExceptionHandlingMiddleware
|
|||||||
|
|
||||||
await context.Response.WriteAsync(json);
|
await context.Response.WriteAsync(json);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static string FormatValidationErrors(FluentValidation.ValidationException exception)
|
private static string FormatValidationErrors(ValidationException exception)
|
||||||
{
|
{
|
||||||
var errors = exception.Errors
|
var errors = exception.Errors
|
||||||
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
||||||
|
|||||||
@@ -1,17 +1,41 @@
|
|||||||
using DigitalData.MessagingService.API.Middleware;
|
using DigitalData.MessagingService.API.Middleware;
|
||||||
using DigitalData.MessagingService.Application;
|
using DigitalData.MessagingService.Application;
|
||||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
|
||||||
using DigitalData.MessagingService.Infrastructure;
|
using DigitalData.MessagingService.Infrastructure;
|
||||||
using Serilog;
|
using Serilog;
|
||||||
|
using Serilog.Ui.Core.Extensions;
|
||||||
|
using Serilog.Ui.SqliteDataProvider.Extensions;
|
||||||
|
using Serilog.Ui.Web.Extensions;
|
||||||
|
|
||||||
// Configure Serilog early
|
// Build temporary configuration to read log directory early
|
||||||
|
var tempConfig = new ConfigurationBuilder()
|
||||||
|
.SetBasePath(Directory.GetCurrentDirectory())
|
||||||
|
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
|
||||||
|
.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: false)
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var logDirectoryRaw = tempConfig.GetValue<string>("Application:LogDirectory") ?? "logs";
|
||||||
|
// Always resolve to an absolute path so sink and UI provider point to the same file
|
||||||
|
var logDirectory = Path.IsPathRooted(logDirectoryRaw)
|
||||||
|
? logDirectoryRaw
|
||||||
|
: Path.Combine(AppContext.BaseDirectory, logDirectoryRaw);
|
||||||
|
Directory.CreateDirectory(logDirectory);
|
||||||
|
var sqliteDbPath = Path.Combine(logDirectory, "logs.db");
|
||||||
|
|
||||||
|
// Configure Serilog early (bootstrap + full pipeline)
|
||||||
Log.Logger = new LoggerConfiguration()
|
Log.Logger = new LoggerConfiguration()
|
||||||
.WriteTo.Console()
|
.MinimumLevel.Information()
|
||||||
|
.MinimumLevel.Override("Microsoft", Serilog.Events.LogEventLevel.Warning)
|
||||||
|
.MinimumLevel.Override("Microsoft.AspNetCore", Serilog.Events.LogEventLevel.Warning)
|
||||||
|
.MinimumLevel.Override("Microsoft.EntityFrameworkCore", Serilog.Events.LogEventLevel.Warning)
|
||||||
|
.MinimumLevel.Override("System", Serilog.Events.LogEventLevel.Warning)
|
||||||
|
.Enrich.FromLogContext()
|
||||||
|
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
path: "logs/emailprofiler-.log",
|
path: Path.Combine(logDirectory, "emailprofiler-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
|
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true)
|
||||||
.CreateBootstrapLogger();
|
.CreateBootstrapLogger();
|
||||||
|
|
||||||
try
|
try
|
||||||
@@ -25,12 +49,13 @@ try
|
|||||||
.ReadFrom.Configuration(context.Configuration)
|
.ReadFrom.Configuration(context.Configuration)
|
||||||
.ReadFrom.Services(services)
|
.ReadFrom.Services(services)
|
||||||
.Enrich.FromLogContext()
|
.Enrich.FromLogContext()
|
||||||
.WriteTo.Console()
|
.WriteTo.Console(outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
.WriteTo.File(
|
.WriteTo.File(
|
||||||
path: "logs/emailprofiler-.log",
|
path: Path.Combine(logDirectory, "emailprofiler-.log"),
|
||||||
rollingInterval: RollingInterval.Day,
|
rollingInterval: RollingInterval.Day,
|
||||||
retainedFileCountLimit: 30,
|
retainedFileCountLimit: 30,
|
||||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"));
|
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
||||||
|
.WriteTo.SQLite(sqliteDbPath, storeTimestampInUtc: true));
|
||||||
|
|
||||||
// Add appsettings.Secrets.json for sensitive configuration (not committed to git)
|
// Add appsettings.Secrets.json for sensitive configuration (not committed to git)
|
||||||
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
||||||
@@ -41,15 +66,21 @@ try
|
|||||||
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
|
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
builder.Services.AddInfrastructure(builder.Configuration);
|
||||||
|
|
||||||
// Register EmailAccount configuration (IOptions<EmailAccountDto>)
|
|
||||||
builder.Services.Configure<EmailAccountDto>(
|
|
||||||
builder.Configuration.GetSection("EmailAccount"));
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
builder.Services.AddControllers();
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
builder.Services.AddEndpointsApiExplorer();
|
||||||
builder.Services.AddSwaggerGen();
|
builder.Services.AddSwaggerGen();
|
||||||
|
|
||||||
|
// Register Serilog.UI with SQLite provider for web log viewer
|
||||||
|
builder.Services.AddSerilogUi(options =>
|
||||||
|
{
|
||||||
|
options.UseSqliteServer(dbOpt =>
|
||||||
|
{
|
||||||
|
dbOpt.WithConnectionString($"Data Source={sqliteDbPath}");
|
||||||
|
dbOpt.WithTable("Logs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
var app = builder.Build();
|
var app = builder.Build();
|
||||||
|
|
||||||
// Add global exception handling middleware
|
// Add global exception handling middleware
|
||||||
@@ -58,8 +89,11 @@ try
|
|||||||
// Add Serilog request logging
|
// Add Serilog request logging
|
||||||
app.UseSerilogRequestLogging();
|
app.UseSerilogRequestLogging();
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
// Configure Swagger — enabled in Development always, and in other environments based on appsettings
|
||||||
if (app.Environment.IsDevelopment())
|
var swaggerEnabled = app.Environment.IsDevelopment()
|
||||||
|
|| app.Configuration.GetValue<bool>("Swagger:Enabled");
|
||||||
|
|
||||||
|
if (swaggerEnabled)
|
||||||
{
|
{
|
||||||
app.UseSwagger();
|
app.UseSwagger();
|
||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
@@ -67,6 +101,9 @@ try
|
|||||||
|
|
||||||
app.UseHttpsRedirection();
|
app.UseHttpsRedirection();
|
||||||
|
|
||||||
|
// Serve Serilog.UI log viewer at /serilog-ui
|
||||||
|
app.UseSerilogUi();
|
||||||
|
|
||||||
app.UseAuthorization();
|
app.UseAuthorization();
|
||||||
|
|
||||||
app.MapControllers();
|
app.MapControllers();
|
||||||
|
|||||||
@@ -4,5 +4,16 @@
|
|||||||
"Default": "Information",
|
"Default": "Information",
|
||||||
"Microsoft.AspNetCore": "Warning"
|
"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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -11,6 +11,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"AllowedHosts": "*",
|
"AllowedHosts": "*",
|
||||||
|
"Application": {
|
||||||
|
"LogDirectory": "logs"
|
||||||
|
},
|
||||||
|
"Swagger": {
|
||||||
|
"Enabled": true
|
||||||
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
"EmailSender": {
|
"EmailSender": {
|
||||||
"Enabled": true
|
"Enabled": true
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<PackageIcon>icon.png</PackageIcon>
|
<PackageIcon>icon.png</PackageIcon>
|
||||||
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
|
<RepositoryUrl>http://git.dd:3000/AppStd/Rec.git</RepositoryUrl>
|
||||||
<PackageTags>digital data messaging service api client</PackageTags>
|
<PackageTags>digital data messaging service api client</PackageTags>
|
||||||
<Version>1.0.0-beta</Version>
|
<Version>1.0.0</Version>
|
||||||
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
<AssemblyVersion>1.0.0.0</AssemblyVersion>
|
||||||
<FileVersion>1.0.0.0</FileVersion>
|
<FileVersion>1.0.0.0</FileVersion>
|
||||||
<Description></Description>
|
<Description></Description>
|
||||||
@@ -21,8 +21,9 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="8.0.1" />
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
<PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
@@ -34,8 +35,12 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<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="..\..\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>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
using DigitalData.MessagingService.Publisher;
|
using DigitalData.MessagingService.Publisher;
|
||||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
|
||||||
using DigitalData.MessagingService.RabbitMQ;
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using System;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Client.DependencyInjection;
|
namespace DigitalData.MessagingService.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Provides a static, self-contained client for sending emails via RabbitMQ
|
/// Provides a static, self-contained client for sending emails via RabbitMQ
|
||||||
@@ -13,14 +13,14 @@ namespace DigitalData.MessagingService.Client.DependencyInjection;
|
|||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This class manages its own internal <see cref="IServiceProvider"/> using a
|
/// This class manages its own internal <see cref="IServiceProvider"/> using a
|
||||||
/// <see cref="Lazy{T}"/> pattern so the DI container is only built once,
|
/// <see cref="Lazy{T}"/> pattern so the DI container is only built once,
|
||||||
/// on the first call to <see cref="ConnectRabbitMq"/>.
|
/// on the first call to <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/>.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static class EmailSender
|
public static class EmailSender
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Internal event used to accumulate service registrations before the
|
/// Internal event used to accumulate service registrations before the
|
||||||
/// <see cref="IServiceProvider"/> is built. Handlers are added by
|
/// <see cref="IServiceProvider"/> is built. Handlers are added by
|
||||||
/// <see cref="ConnectRabbitMq"/> and invoked exactly once during
|
/// <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> and invoked exactly once during
|
||||||
/// lazy initialization.
|
/// lazy initialization.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static event Action<IServiceCollection> ConfigureServices = delegate { };
|
private static event Action<IServiceCollection> ConfigureServices = delegate { };
|
||||||
@@ -33,6 +33,7 @@ public static class EmailSender
|
|||||||
private static readonly Lazy<IServiceProvider> LazyProvider = new(() =>
|
private static readonly Lazy<IServiceProvider> LazyProvider = new(() =>
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
ConfigureServices?.Invoke(services);
|
ConfigureServices?.Invoke(services);
|
||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
});
|
});
|
||||||
@@ -42,7 +43,7 @@ public static class EmailSender
|
|||||||
/// and the internal <see cref="IServiceProvider"/> has been initialized.
|
/// and the internal <see cref="IServiceProvider"/> has been initialized.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <value>
|
/// <value>
|
||||||
/// <see langword="true"/> if <see cref="ConnectRabbitMq"/> has been called
|
/// <see langword="true"/> if <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> has been called
|
||||||
/// and the provider is built; otherwise <see langword="false"/>.
|
/// and the provider is built; otherwise <see langword="false"/>.
|
||||||
/// </value>
|
/// </value>
|
||||||
public static bool IsConnected => LazyProvider.IsValueCreated;
|
public static bool IsConnected => LazyProvider.IsValueCreated;
|
||||||
@@ -106,24 +107,30 @@ public static class EmailSender
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Enqueues the specified email event to the RabbitMQ messaging pipeline.
|
/// Enqueues the specified email to the RabbitMQ messaging pipeline.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="email">The outgoing email event to enqueue.</param>
|
/// <param name="email">The outgoing email data to enqueue.</param>
|
||||||
/// <exception cref="InvalidOperationException">
|
/// <exception cref="InvalidOperationException">
|
||||||
/// Thrown when <see cref="ConnectRabbitMq"/> has not been called prior to sending.
|
/// Thrown when <see cref="ConnectRabbitMq(Action{RabbitMqConfiguration}, OnReconnect)"/> has not been called prior to sending.
|
||||||
/// </exception>
|
/// </exception>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// This method 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.
|
/// service provider and calls <c>EnqueueAsync</c> in a fire-and-forget manner.
|
||||||
/// Ensure that any unhandled exceptions from the async operation are handled
|
/// Ensure that any unhandled exceptions from the async operation are handled
|
||||||
/// at the publisher level.
|
/// at the publisher level.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
public static void Send(OutgoingEmailEvent email)
|
public static void Send(EmailContext email)
|
||||||
{
|
{
|
||||||
if(!IsConnected)
|
if(!IsConnected)
|
||||||
throw new InvalidOperationException("Messaging service is not connected. Call ConnectRabbitMq first.");
|
throw new InvalidOperationException("Messaging service is not connected. Call ConnectRabbitMq first.");
|
||||||
|
|
||||||
var publisher = LazyProvider.Value.GetRequiredService<IOutgoingEmailPublisher>();
|
var publisher = LazyProvider.Value.GetRequiredService<ISendingEmailPublisher>();
|
||||||
publisher.EnqueueAsync(email);
|
publisher.EnqueueAsync(new SendingEmailEvent()
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = email,
|
||||||
|
QueuedAt = DateTime.Now
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
namespace DigitalData.MessagingService.Client.DependencyInjection;
|
namespace DigitalData.MessagingService.Client;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Defines the behavior when <see cref="EmailSender.ConnectRabbitMq"/> is called
|
/// Defines the behavior when <see cref="EmailSender.ConnectRabbitMq(Action{RabbitMQ.RabbitMqConfiguration}, OnReconnect)"/> is called
|
||||||
/// while a connection has already been established.
|
/// while a connection has already been established.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public enum OnReconnect
|
public enum OnReconnect
|
||||||
185
src/presentation/DigitalData.MessagingService.Client/README.md
Normal file
185
src/presentation/DigitalData.MessagingService.Client/README.md
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
# DigitalData.MessagingService.Client
|
||||||
|
|
||||||
|
Ein schlanker, eigenständiger RabbitMQ-Client zum Versenden von E-Mails über den DigitalData MessagingService – ohne eigenen DI-Container oder Hosting-Infrastruktur.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
```
|
||||||
|
dotnet add package DigitalData.MessagingService.Client
|
||||||
|
```
|
||||||
|
|
||||||
|
## Schnellstart
|
||||||
|
|
||||||
|
### 1. Verbindung herstellen
|
||||||
|
|
||||||
|
Einmalig beim Anwendungsstart aufrufen – typischerweise in `Program.cs`, `Application_Start` oder dem Konstruktor des Einstiegspunkts:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
url: "amqp://172.24.12.56:5672",
|
||||||
|
username: "admin",
|
||||||
|
password: "geheimespasswort"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```vb
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
url:="amqp://172.24.12.56:5672",
|
||||||
|
username:="admin",
|
||||||
|
password:="geheimespasswort"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
Mit virtuellem Host:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
url: "amqp://172.24.12.56:5672/meinvhost",
|
||||||
|
username: "admin",
|
||||||
|
password: "geheimespasswort"
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
```vb
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
url:="amqp://172.24.12.56:5672/meinvhost",
|
||||||
|
username:="admin",
|
||||||
|
password:="geheimespasswort"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Hinweis:** `ConnectRabbitMq` darf pro Prozess nur einmal erfolgreich aufgerufen werden.
|
||||||
|
> Ein erneuter Aufruf löst standardmäßig eine `InvalidOperationException` aus.
|
||||||
|
> Ist dieses Verhalten nicht erwünscht, kann `OnReconnect.Ignore` übergeben werden:
|
||||||
|
>
|
||||||
|
> ```csharp
|
||||||
|
> EmailSender.ConnectRabbitMq("amqp://172.24.12.56:5672", "admin", "geheimespasswort", OnReconnect.Ignore);
|
||||||
|
> ```
|
||||||
|
>
|
||||||
|
> ```vb
|
||||||
|
> EmailSender.ConnectRabbitMq("amqp://172.24.12.56:5672", "admin", "geheimespasswort", OnReconnect.Ignore)
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 2. E-Mail versenden
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
EmailSender.Send(new Email
|
||||||
|
{
|
||||||
|
Recipient = "empfaenger@beispiel.de",
|
||||||
|
Subject = "Willkommen",
|
||||||
|
Body = "<p>Hallo Welt!</p>",
|
||||||
|
IsHtml = true
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```vb
|
||||||
|
EmailSender.Send(New Email With {
|
||||||
|
.Recipient = "empfaenger@beispiel.de",
|
||||||
|
.Subject = "Willkommen",
|
||||||
|
.Body = "<p>Hallo Welt!</p>",
|
||||||
|
.IsHtml = True
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`Send` ist eine **Fire-and-Forget**-Methode: die Nachricht wird in die RabbitMQ-Queue eingereiht und der aufrufende Code wartet nicht auf die eigentliche Zustellung.
|
||||||
|
|
||||||
|
> **Hinweis:** `Id` und `QueuedAt` werden intern automatisch gesetzt. Im `Email`-Objekt müssen nur `Recipient`, `Subject`, `Body` und `IsHtml` angegeben werden.
|
||||||
|
|
||||||
|
### 3. Verbindungsstatus prüfen
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
if (EmailSender.IsConnected)
|
||||||
|
{
|
||||||
|
// Verbindung wurde bereits hergestellt
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```vb
|
||||||
|
If EmailSender.IsConnected Then
|
||||||
|
' Verbindung wurde bereits hergestellt
|
||||||
|
End If
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## URL-Format
|
||||||
|
|
||||||
|
```
|
||||||
|
amqp://<host>:<port>[/<virtualhost>]
|
||||||
|
```
|
||||||
|
|
||||||
|
| Bestandteil | Beschreibung | Beispiel |
|
||||||
|
|---------------|---------------------------------------------------------|----------------|
|
||||||
|
| `host` | Hostname oder IP-Adresse des RabbitMQ-Servers | `172.24.12.56` |
|
||||||
|
| `port` | AMQP-Port (Standard: `5672`) | `5672` |
|
||||||
|
| `virtualhost` | Optionaler virtueller Host; URL-Encoding wird aufgelöst | `meinvhost` |
|
||||||
|
|
||||||
|
Benutzername und Passwort werden **nicht** aus der URL gelesen, sondern immer separat als Parameter übergeben. Dadurch werden Klartext-Credentials in URLs vermieden.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## RabbitMQ-Server
|
||||||
|
|
||||||
|
| | |
|
||||||
|
|---|---|
|
||||||
|
| **AMQP** | `amqp://172.24.12.56:5672` |
|
||||||
|
| **Management UI** | http://172.24.12.56:15672 (Browser) |
|
||||||
|
| **Benutzername** | `admin` |
|
||||||
|
| **Passwort** | Im RDM-Eintrag **`sDD-VMP05-VM06 - 172.24.12.56 - RabbitMQ`** hinterlegt |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Voraussetzungen
|
||||||
|
|
||||||
|
- .NET Framework 4.6.2 / 4.8 oder .NET 8+
|
||||||
|
- Erreichbarer RabbitMQ-Server
|
||||||
|
- Exchange, Queue und Routing Key müssen auf dem Broker vorhanden sein (werden vom Server-seitigen Consumer angelegt)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Erweiterte Konfiguration
|
||||||
|
|
||||||
|
Für spezielle Szenarien – etwa abweichende Exchange- oder Queue-Namen – steht ein Delegate-basierter Überload zur Verfügung:
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
EmailSender.ConnectRabbitMq(cfg =>
|
||||||
|
{
|
||||||
|
cfg.HostName = "172.24.12.56";
|
||||||
|
cfg.Port = 5672;
|
||||||
|
cfg.UserName = "admin";
|
||||||
|
cfg.Password = "geheimespasswort";
|
||||||
|
cfg.VirtualHost = "/";
|
||||||
|
// cfg.ExchangeName, cfg.QueueName, cfg.RoutingKey usw. bei Bedarf anpassen
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
```vb
|
||||||
|
EmailSender.ConnectRabbitMq(Sub(cfg)
|
||||||
|
cfg.HostName = "172.24.12.56"
|
||||||
|
cfg.Port = 5672
|
||||||
|
cfg.UserName = "admin"
|
||||||
|
cfg.Password = "geheimespasswort"
|
||||||
|
cfg.VirtualHost = "/"
|
||||||
|
' cfg.ExchangeName, cfg.QueueName, cfg.RoutingKey usw. bei Bedarf anpassen
|
||||||
|
End Sub)
|
||||||
|
```
|
||||||
|
|
||||||
|
Dieser Überload ist für den Normalbetrieb nicht erforderlich.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Fehlerbehandlung
|
||||||
|
|
||||||
|
| Situation | Verhalten |
|
||||||
|
|---|---|
|
||||||
|
| `ConnectRabbitMq` noch nicht aufgerufen, dann `Send` | `InvalidOperationException` |
|
||||||
|
| `ConnectRabbitMq` erneut aufgerufen (Standard) | `InvalidOperationException` |
|
||||||
|
| `ConnectRabbitMq` erneut aufgerufen mit `OnReconnect.Ignore` | Wird stillschweigend ignoriert |
|
||||||
|
| RabbitMQ nicht erreichbar beim ersten `Send` | Exception aus dem RabbitMQ-Client |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lizenz
|
||||||
|
|
||||||
|
Copyright © 2026 Digital Data GmbH. Alle Rechte vorbehalten.
|
||||||
|
|
||||||
@@ -11,11 +11,21 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
<PackageReference Include="coverlet.collector" Version="6.0.0" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.10" />
|
||||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.8.0" />
|
||||||
|
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||||
<PackageReference Include="xunit" Version="2.5.3" />
|
<PackageReference Include="xunit" Version="2.5.3" />
|
||||||
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
<PackageReference Include="xunit.runner.visualstudio" Version="2.5.3" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\..\src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||||
|
<ProjectReference Include="..\..\src\presentation\DigitalData.MessagingService.Client\DigitalData.MessagingService.Client.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Using Include="Xunit" />
|
<Using Include="Xunit" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Client;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// xUnit collection that serializes all <see cref="EmailSenderTests"/> so they share
|
||||||
|
/// the same process-wide static state of <see cref="EmailSender.LazyProvider"/>.
|
||||||
|
/// </summary>
|
||||||
|
[CollectionDefinition(Name)]
|
||||||
|
public sealed class EmailSenderCollection : ICollectionFixture<EmailSenderFixture>
|
||||||
|
{
|
||||||
|
public const string Name = "EmailSender";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Fixture that connects <see cref="EmailSender"/> once before all tests in the collection run.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EmailSenderFixture
|
||||||
|
{
|
||||||
|
public EmailSenderFixture()
|
||||||
|
{
|
||||||
|
// EmailSender is a static class with a Lazy<IServiceProvider>.
|
||||||
|
// ConnectRabbitMq can only be called successfully once per process.
|
||||||
|
if (!EmailSender.IsConnected)
|
||||||
|
{
|
||||||
|
EmailSender.ConnectRabbitMq(cfg =>
|
||||||
|
{
|
||||||
|
cfg.HostName = RabbitMqTestConfig.HostName;
|
||||||
|
cfg.Port = RabbitMqTestConfig.Port;
|
||||||
|
cfg.UserName = RabbitMqTestConfig.UserName;
|
||||||
|
cfg.Password = RabbitMqTestConfig.Password;
|
||||||
|
cfg.VirtualHost = RabbitMqTestConfig.VirtualHost;
|
||||||
|
cfg.QueueName = RabbitMqTestConfig.QueueName;
|
||||||
|
cfg.ExchangeName = RabbitMqTestConfig.ExchangeName;
|
||||||
|
cfg.RoutingKey = RabbitMqTestConfig.RoutingKey;
|
||||||
|
cfg.DlqQueueName = RabbitMqTestConfig.DlqQueueName;
|
||||||
|
cfg.DlqExchangeName = RabbitMqTestConfig.DlqExchangeName;
|
||||||
|
cfg.DlqRoutingKey = RabbitMqTestConfig.DlqRoutingKey;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the <see cref="EmailSender"/> static client.
|
||||||
|
/// All tests run inside <see cref="EmailSenderCollection"/> to share the single
|
||||||
|
/// static connection established by <see cref="EmailSenderFixture"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(EmailSenderCollection.Name)]
|
||||||
|
public sealed class EmailSenderTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void IsConnected_AfterConnectRabbitMq_ReturnsTrue()
|
||||||
|
{
|
||||||
|
Assert.True(EmailSender.IsConnected);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectRabbitMq_WhenAlreadyConnected_WithThrowException_ThrowsInvalidOperationException()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.ThrowException));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectRabbitMq_WhenAlreadyConnected_WithIgnore_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var exception = Record.Exception(() =>
|
||||||
|
EmailSender.ConnectRabbitMq(cfg => { }, OnReconnect.Ignore));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Send_WithValidEmail_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var email = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => EmailSender.Send(email));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Send_WithPlainTextBody_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var email = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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
|
||||||
|
};
|
||||||
|
|
||||||
|
var exception = Record.Exception(() => EmailSender.Send(email));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Client;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Integration tests for the URL-based <see cref="EmailSender.ConnectRabbitMq(string, string, string, OnReconnect)"/>
|
||||||
|
/// overload. All tests run inside <see cref="EmailSenderCollection"/> so they share the already-established
|
||||||
|
/// static connection. The URL overload is exercised via <see cref="OnReconnect.Ignore"/> and
|
||||||
|
/// <see cref="OnReconnect.ThrowException"/> to verify its delegation and guard behavior.
|
||||||
|
/// </summary>
|
||||||
|
[Collection(EmailSenderCollection.Name)]
|
||||||
|
public sealed class EmailSenderUrlOverloadTests
|
||||||
|
{
|
||||||
|
private static readonly string ValidUrl = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}";
|
||||||
|
private static readonly string ValidUrlWithVHost = $"amqp://{RabbitMqTestConfig.HostName}:{RabbitMqTestConfig.Port}/myvhost";
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithIgnore_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var exception = Record.Exception(() =>
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
ValidUrl,
|
||||||
|
RabbitMqTestConfig.UserName,
|
||||||
|
RabbitMqTestConfig.Password,
|
||||||
|
OnReconnect.Ignore));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectRabbitMq_UrlOverload_WhenAlreadyConnected_WithThrowException_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<InvalidOperationException>(() =>
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
ValidUrl,
|
||||||
|
RabbitMqTestConfig.UserName,
|
||||||
|
RabbitMqTestConfig.Password,
|
||||||
|
OnReconnect.ThrowException));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConnectRabbitMq_UrlOverload_WithVHostInPath_WithIgnore_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var exception = Record.Exception(() =>
|
||||||
|
EmailSender.ConnectRabbitMq(
|
||||||
|
ValidUrlWithVHost,
|
||||||
|
RabbitMqTestConfig.UserName,
|
||||||
|
RabbitMqTestConfig.Password,
|
||||||
|
OnReconnect.Ignore));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Send_AfterUrlOverloadConnection_DoesNotThrow()
|
||||||
|
{
|
||||||
|
var email = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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
|
||||||
|
};
|
||||||
|
|
||||||
|
// Connection was established via Action<> overload in the fixture;
|
||||||
|
// Send should work regardless of which overload was used to connect.
|
||||||
|
var exception = Record.Exception(() => EmailSender.Send(email));
|
||||||
|
|
||||||
|
Assert.Null(exception);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,211 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
using DigitalData.MessagingService.Publisher;
|
||||||
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Tests.Integration;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 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 SendingEmailPublisherTests : IAsyncDisposable
|
||||||
|
{
|
||||||
|
private readonly ServiceProvider _serviceProvider;
|
||||||
|
private readonly ISendingEmailPublisher _publisher;
|
||||||
|
private readonly RabbitMqConnectionFactory _factory;
|
||||||
|
|
||||||
|
public SendingEmailPublisherTests()
|
||||||
|
{
|
||||||
|
var services = new ServiceCollection();
|
||||||
|
services.AddLogging();
|
||||||
|
services.AddMessagingServicePublisher(RabbitMqTestConfig.Apply);
|
||||||
|
|
||||||
|
_serviceProvider = services.BuildServiceProvider();
|
||||||
|
_publisher = _serviceProvider.GetRequiredService<ISendingEmailPublisher>();
|
||||||
|
_factory = _serviceProvider.GetRequiredService<RabbitMqConnectionFactory>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueAsync_PublishesMessage_MessageArrivesInQueue()
|
||||||
|
{
|
||||||
|
var email = new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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
|
||||||
|
};
|
||||||
|
|
||||||
|
var depthBefore = await _publisher.GetQueueDepthAsync();
|
||||||
|
|
||||||
|
await _publisher.EnqueueAsync(email);
|
||||||
|
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
var depthAfter = await _publisher.GetQueueDepthAsync();
|
||||||
|
|
||||||
|
Assert.True(depthAfter >= depthBefore + 1,
|
||||||
|
$"Expected queue depth to increase by at least 1. Before: {depthBefore}, After: {depthAfter}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueAsync_MultipleMessages_AllArrivesInQueue()
|
||||||
|
{
|
||||||
|
var emails = Enumerable.Range(1, 3).Select(i => new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { Username = "test@example.com", Password = "password", SmtpServer = "smtp.example.com" },
|
||||||
|
Recipients = [$"recipient{i}@example.com"],
|
||||||
|
Subject = $"Integration Test - Batch #{i}",
|
||||||
|
Body = $"Batch message {i}",
|
||||||
|
IsHtml = false,
|
||||||
|
},
|
||||||
|
QueuedAt = DateTime.Now
|
||||||
|
}).ToList();
|
||||||
|
|
||||||
|
foreach (var email in emails)
|
||||||
|
await _publisher.EnqueueAsync(email);
|
||||||
|
|
||||||
|
await Task.Delay(500);
|
||||||
|
|
||||||
|
// Verify at least one message is present
|
||||||
|
var received = await PeekMessageAsync();
|
||||||
|
Assert.NotNull(received);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetQueueDepthAsync_AfterPublish_ReturnsPositiveDepth()
|
||||||
|
{
|
||||||
|
var email = new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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
|
||||||
|
};
|
||||||
|
|
||||||
|
await _publisher.EnqueueAsync(email);
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
var depth = await _publisher.GetQueueDepthAsync();
|
||||||
|
|
||||||
|
Assert.True(depth > 0, $"Expected queue depth > 0, but got {depth}.");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task EnqueueAsync_SerializesAllFields_DeserializesCorrectly()
|
||||||
|
{
|
||||||
|
var id = Guid.NewGuid();
|
||||||
|
|
||||||
|
var email = new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = id,
|
||||||
|
Mail = new EmailContext
|
||||||
|
{
|
||||||
|
Sender = new EmailAccount { 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
|
||||||
|
};
|
||||||
|
|
||||||
|
await _publisher.EnqueueAsync(email);
|
||||||
|
await Task.Delay(300);
|
||||||
|
|
||||||
|
// Read messages until we find the one we just published
|
||||||
|
var received = await FindMessageAsync(id);
|
||||||
|
|
||||||
|
Assert.NotNull(received);
|
||||||
|
Assert.Equal(id, received.Id);
|
||||||
|
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<SendingEmailEvent?> PeekMessageAsync()
|
||||||
|
{
|
||||||
|
var connection = await _factory.GetDefaultConnectionAsync();
|
||||||
|
await using var channel = await connection.CreateChannelAsync();
|
||||||
|
|
||||||
|
var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
return null;
|
||||||
|
|
||||||
|
// Nack with requeue=true so the message stays in the queue for consumer
|
||||||
|
await channel.BasicNackAsync(result.DeliveryTag, multiple: false, requeue: true);
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(result.Body.ToArray());
|
||||||
|
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<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)>();
|
||||||
|
|
||||||
|
SendingEmailEvent? found = null;
|
||||||
|
|
||||||
|
for (int i = 0; i < maxMessages; i++)
|
||||||
|
{
|
||||||
|
var result = await channel.BasicGetAsync(RabbitMqTestConfig.QueueName, autoAck: false);
|
||||||
|
if (result is null)
|
||||||
|
break;
|
||||||
|
|
||||||
|
requeue.Add((result.DeliveryTag, result.Body.ToArray()));
|
||||||
|
|
||||||
|
var json = Encoding.UTF8.GetString(result.Body.ToArray());
|
||||||
|
var evt = JsonSerializer.Deserialize<SendingEmailEvent>(json);
|
||||||
|
|
||||||
|
if (evt?.Id == id)
|
||||||
|
{
|
||||||
|
found = evt;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-queue all inspected messages so the consumer can still process them
|
||||||
|
foreach (var (tag, _) in requeue)
|
||||||
|
await channel.BasicNackAsync(tag, multiple: false, requeue: true);
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
await _factory.DisposeAsync();
|
||||||
|
await _serviceProvider.DisposeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Shared RabbitMQ connection settings used across integration tests.
|
||||||
|
/// Reads from the real broker defined in appsettings.Secrets.json.
|
||||||
|
/// </summary>
|
||||||
|
internal static class RabbitMqTestConfig
|
||||||
|
{
|
||||||
|
public const string HostName = "172.24.12.56";
|
||||||
|
public const int Port = 5672;
|
||||||
|
public const string UserName = "admin";
|
||||||
|
public const string Password = "fl!'D}4;pYBb\\VD&{6]]G*\\0Bq8fVIn0j?Sgm\\2A,6GS47g5Dj";
|
||||||
|
public const string VirtualHost = "/";
|
||||||
|
|
||||||
|
public const string QueueName = "emailprofiler.email.outbox";
|
||||||
|
public const string ExchangeName = "emailprofiler.emails";
|
||||||
|
public const string RoutingKey = "email.outbox";
|
||||||
|
public const string DlqQueueName = "emailprofiler.email.outbox.dlq";
|
||||||
|
public const string DlqExchangeName = "emailprofiler.emails.dlq";
|
||||||
|
public const string DlqRoutingKey = "email.outbox.dlq";
|
||||||
|
|
||||||
|
public static void Apply(RabbitMqConfiguration cfg)
|
||||||
|
{
|
||||||
|
cfg.HostName = HostName;
|
||||||
|
cfg.Port = Port;
|
||||||
|
cfg.UserName = UserName;
|
||||||
|
cfg.Password = Password;
|
||||||
|
cfg.VirtualHost = VirtualHost;
|
||||||
|
cfg.QueueName = QueueName;
|
||||||
|
cfg.ExchangeName = ExchangeName;
|
||||||
|
cfg.RoutingKey = RoutingKey;
|
||||||
|
cfg.DlqQueueName = DlqQueueName;
|
||||||
|
cfg.DlqExchangeName = DlqExchangeName;
|
||||||
|
cfg.DlqRoutingKey = DlqRoutingKey;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
using DigitalData.MessagingService.RabbitMQ;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Tests.Unit;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Unit tests for the URL parsing logic inside
|
||||||
|
/// <see cref="DigitalData.MessagingService.Client.DependencyInjection.EmailSender.ConnectRabbitMq(string, string, string, OnReconnect)"/>.
|
||||||
|
/// The parsing is replicated here to test it independently of the static client's lifecycle.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class EmailSenderUrlParsingTests
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Applies the same URL-parsing logic used by the URL overload to a fresh
|
||||||
|
/// <see cref="RabbitMqConfiguration"/> and returns it for assertion.
|
||||||
|
/// </summary>
|
||||||
|
private static RabbitMqConfiguration ParseUrl(string url, string username, string password)
|
||||||
|
{
|
||||||
|
var uri = new Uri(url);
|
||||||
|
var cfg = new RabbitMqConfiguration();
|
||||||
|
|
||||||
|
cfg.HostName = uri.Host;
|
||||||
|
cfg.Port = uri.IsDefaultPort ? 5672 : uri.Port;
|
||||||
|
cfg.UserName = username;
|
||||||
|
cfg.Password = password;
|
||||||
|
if (!string.IsNullOrEmpty(uri.AbsolutePath) && uri.AbsolutePath != "/")
|
||||||
|
cfg.VirtualHost = Uri.UnescapeDataString(uri.AbsolutePath.TrimStart('/'));
|
||||||
|
|
||||||
|
return cfg;
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithHostAndPort_SetsHostNameAndPort()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal("mybroker", cfg.HostName);
|
||||||
|
Assert.Equal(5672, cfg.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithCustomPort_SetsCustomPort()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://mybroker:5700", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal(5700, cfg.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithDefaultAmqpPort_FallsBackTo5672()
|
||||||
|
{
|
||||||
|
// amqp:// does not have a registered default port in .NET's Uri,
|
||||||
|
// so IsDefaultPort is false; the explicit port is used as-is.
|
||||||
|
var cfg = ParseUrl("amqp://mybroker:5672", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal(5672, cfg.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithoutPort_DefaultsTo5672()
|
||||||
|
{
|
||||||
|
// When no port is specified, Uri.IsDefaultPort is true for known schemes
|
||||||
|
// or Uri.Port returns -1. The overload falls back to 5672 when IsDefaultPort.
|
||||||
|
var uri = new Uri("amqp://mybroker");
|
||||||
|
var cfg = ParseUrl($"amqp://mybroker{(uri.IsDefaultPort ? "" : $":{uri.Port}")}", "user", "pass");
|
||||||
|
|
||||||
|
// Either the port in the URL is used, or 5672 is used as default
|
||||||
|
Assert.True(cfg.Port == 5672 || cfg.Port == uri.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_SetsUsernameAndPassword()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://broker:5672", "admin", "s3cr3t");
|
||||||
|
|
||||||
|
Assert.Equal("admin", cfg.UserName);
|
||||||
|
Assert.Equal("s3cr3t", cfg.Password);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithVHostInPath_SetsVirtualHost()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://broker:5672/myvhost", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal("myvhost", cfg.VirtualHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithUrlEncodedVHost_DecodesVirtualHost()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://broker:5672/my%2Fvhost", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal("my/vhost", cfg.VirtualHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithRootPath_DoesNotOverrideVirtualHost()
|
||||||
|
{
|
||||||
|
var defaultVHost = new RabbitMqConfiguration().VirtualHost;
|
||||||
|
var cfg = ParseUrl("amqp://broker:5672/", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal(defaultVHost, cfg.VirtualHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithoutPath_DoesNotOverrideVirtualHost()
|
||||||
|
{
|
||||||
|
var defaultVHost = new RabbitMqConfiguration().VirtualHost;
|
||||||
|
var cfg = ParseUrl("amqp://broker:5672", "user", "pass");
|
||||||
|
|
||||||
|
Assert.Equal(defaultVHost, cfg.VirtualHost);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_WithIpAddress_SetsHostName()
|
||||||
|
{
|
||||||
|
var cfg = ParseUrl("amqp://172.24.12.56:5672", "admin", "pass");
|
||||||
|
|
||||||
|
Assert.Equal("172.24.12.56", cfg.HostName);
|
||||||
|
Assert.Equal(5672, cfg.Port);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ParseUrl_InvalidUrl_ThrowsUriFormatException()
|
||||||
|
{
|
||||||
|
Assert.Throws<UriFormatException>(() =>
|
||||||
|
ParseUrl("not-a-valid-url", "user", "pass"));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user