Compare commits
146 Commits
5e587da957
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 993d9cfea0 | |||
| 8925428e46 | |||
| c189a2d6ef | |||
| 408ad14c9f | |||
| 08547a8768 | |||
| 5746d36665 | |||
| a9046e957b | |||
| 3ddd5e83a2 | |||
| 6ff01b364c | |||
| bb9ff9c9ed | |||
| ba8f701223 | |||
| 6d6e876d94 | |||
| 502ae5e07b | |||
| c90040db67 | |||
| f0c698856d | |||
| 158b562007 | |||
| a0f1fcddac | |||
| f7d5abb132 | |||
| 7782e86db3 | |||
| 1d7f269fe1 | |||
| a13380016d | |||
| 822c7b1352 | |||
| f7e95eb7f7 | |||
| a57429718d | |||
| ef8df96e65 | |||
| 194ae11a40 | |||
| 6c698c95be | |||
| 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 | |||
| 6183ea613f | |||
| e68fa989e1 | |||
| de44b1967f | |||
| 6eebfed97e | |||
| e8359abafd | |||
| a38e8f9680 | |||
| 7f55d97352 | |||
| 472c9506f9 | |||
| 47f4553986 | |||
| 61c6e34b2d | |||
| ac4682575c | |||
| cd221e710e | |||
| 78c82bf129 | |||
| 2ad2dc6b4d | |||
| 3149bb1cf9 | |||
| 0b3ff7cae9 | |||
| 977a20fd97 | |||
| 56ac720615 | |||
| becb608331 | |||
| 37009b8e1e | |||
| b2d478c335 | |||
| bf06b31656 | |||
| 77e7c796fa | |||
| 2d7af80cd3 | |||
| a2373f242a | |||
| 3611d527d4 | |||
| 885365df76 | |||
| 94d1b73c4a | |||
| 84c72af993 | |||
| 0eda732d49 | |||
| 42d35d9a01 | |||
| 23d52b9427 | |||
| 370872e126 | |||
| b6470fce5a | |||
| 1617d4ab43 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -371,3 +371,6 @@ 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
|
||||||
|
/src/presentation/DigitalData.MessagingService.API/oauth.htek0100@gmail.com.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);
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,6 @@ VisualStudioVersion = 17.14.36717.8
|
|||||||
MinimumVisualStudioVersion = 10.0.40219.1
|
MinimumVisualStudioVersion = 10.0.40219.1
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{456817AB-67A3-49DD-9EC5-1253A727B958}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{D2C32417-FE92-4063-97A9-3DA8D811E561}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{76ADC1D0-4DFA-0B1E-57C9-2636434A0043}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Application", "src\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj", "{1874A827-C6A5-EB5E-0FE9-30A7200382B7}"
|
|
||||||
EndProject
|
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Tests", "tests\DigitalData.MessagingService.Tests\DigitalData.MessagingService.Tests.csproj", "{211FB65F-2406-474E-A426-DA246B250AB8}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Tests", "tests\DigitalData.MessagingService.Tests\DigitalData.MessagingService.Tests.csproj", "{211FB65F-2406-474E-A426-DA246B250AB8}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F20FEFD-9289-42C6-ABA6-8DB236D74559}"
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F20FEFD-9289-42C6-ABA6-8DB236D74559}"
|
||||||
@@ -25,42 +17,80 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution
|
|||||||
STATUS.md = STATUS.md
|
STATUS.md = STATUS.md
|
||||||
EndProjectSection
|
EndProjectSection
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "presentation", "presentation", "{B52B4CEE-1C67-424B-8659-370FEA7EAF2A}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "core", "core", "{DD9D4A3A-AB55-456E-80D3-54A2D4025E64}"
|
||||||
|
EndProject
|
||||||
|
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "infrastructure", "infrastructure", "{71BEA4D0-7835-4A8C-B11E-1088E0801DCE}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Application", "src\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj", "{7CBE8648-F259-CC91-87FF-5859280867A8}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Domain", "src\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj", "{8E44FA5B-43DD-E273-C682-FC382A854A6D}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Infrastructure", "src\infrastructure\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj", "{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.RabbitMQ", "src\infrastructure\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj", "{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.API", "src\presentation\DigitalData.MessagingService.API\DigitalData.MessagingService.API.csproj", "{8BF22107-3CB9-C326-B94B-C40C99DA9B68}"
|
||||||
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Publisher", "src\infrastructure\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj", "{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC}"
|
||||||
|
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
|
||||||
Release|Any CPU = Release|Any CPU
|
Release|Any CPU = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||||
{456817AB-67A3-49DD-9EC5-1253A727B958}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{456817AB-67A3-49DD-9EC5-1253A727B958}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{456817AB-67A3-49DD-9EC5-1253A727B958}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{456817AB-67A3-49DD-9EC5-1253A727B958}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{D2C32417-FE92-4063-97A9-3DA8D811E561}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{D2C32417-FE92-4063-97A9-3DA8D811E561}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{D2C32417-FE92-4063-97A9-3DA8D811E561}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{D2C32417-FE92-4063-97A9-3DA8D811E561}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7}.Release|Any CPU.Build.0 = Release|Any CPU
|
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
{211FB65F-2406-474E-A426-DA246B250AB8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{7CBE8648-F259-CC91-87FF-5859280867A8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{7CBE8648-F259-CC91-87FF-5859280867A8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{7CBE8648-F259-CC91-87FF-5859280867A8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{7CBE8648-F259-CC91-87FF-5859280867A8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8E44FA5B-43DD-E273-C682-FC382A854A6D}.Debug|Any CPU.ActiveCfg = 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.Build.0 = Release|Any CPU
|
||||||
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68}.Debug|Any CPU.ActiveCfg = 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.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.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.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
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(NestedProjects) = preSolution
|
GlobalSection(NestedProjects) = preSolution
|
||||||
{456817AB-67A3-49DD-9EC5-1253A727B958} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
|
||||||
{D2C32417-FE92-4063-97A9-3DA8D811E561} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
|
||||||
{76ADC1D0-4DFA-0B1E-57C9-2636434A0043} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
|
||||||
{1874A827-C6A5-EB5E-0FE9-30A7200382B7} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
|
||||||
{211FB65F-2406-474E-A426-DA246B250AB8} = {4F20FEFD-9289-42C6-ABA6-8DB236D74559}
|
{211FB65F-2406-474E-A426-DA246B250AB8} = {4F20FEFD-9289-42C6-ABA6-8DB236D74559}
|
||||||
|
{B52B4CEE-1C67-424B-8659-370FEA7EAF2A} = {02EA681E-C7D8-13C7-8484-4AC65E1B71E8}
|
||||||
|
{DD9D4A3A-AB55-456E-80D3-54A2D4025E64} = {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}
|
||||||
|
{8E44FA5B-43DD-E273-C682-FC382A854A6D} = {DD9D4A3A-AB55-456E-80D3-54A2D4025E64}
|
||||||
|
{56607AAB-3DEC-CB78-3062-56A8EEF5E9D2} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
|
{4CF993A6-FA3E-CBF7-C4CB-FFAEBFCFF705} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
|
{8BF22107-3CB9-C326-B94B-C40C99DA9B68} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
|
{8DBBAA7C-C4D3-4ADD-8372-B0D6260C8FFC} = {71BEA4D0-7835-4A8C-B11E-1088E0801DCE}
|
||||||
|
{770E96B0-C3C9-A9A3-4F98-F7A0295D1599} = {B52B4CEE-1C67-424B-8659-370FEA7EAF2A}
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||||
|
|||||||
300
README.md
300
README.md
@@ -1,2 +1,302 @@
|
|||||||
# DigitalData.MessagingService
|
# DigitalData.MessagingService
|
||||||
|
|
||||||
|
A .NET 8 messaging service for sending and receiving emails via SMTP, IMAP, POP3 and OAuth2, with RabbitMQ-based async delivery.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Account Configuration
|
||||||
|
|
||||||
|
Each account is configured under `EmailAccounts.Accounts` in `appsettings.Secrets.json`.
|
||||||
|
|
||||||
|
> Different providers require different configuration fields. See provider-specific sections below.
|
||||||
|
|
||||||
|
### Common fields (all providers)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Username": "user@example.com",
|
||||||
|
"Password": "your_password",
|
||||||
|
"SmtpServer": "smtp.example.com",
|
||||||
|
"SmtpPort": 465,
|
||||||
|
"SmtpUseSsl": true,
|
||||||
|
"UseOAuth2": false,
|
||||||
|
"ImapServer": "imap.example.com",
|
||||||
|
"ImapPort": 993,
|
||||||
|
"ImapUseSsl": true,
|
||||||
|
"Pop3Server": "pop.example.com",
|
||||||
|
"Pop3Port": 995,
|
||||||
|
"Pop3UseSsl": true,
|
||||||
|
"IncomingProtocol": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `Password` is always retained. When `UseOAuth2 = true`, SMTP/IMAP/POP3 connections use OAuth2 tokens
|
||||||
|
> instead of the password. When `UseOAuth2 = false`, the password is used directly.
|
||||||
|
> The `IncomingProtocol` field independently controls which protocol is used for receiving emails.
|
||||||
|
|
||||||
|
#### `IncomingProtocol` values
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `0` | None — send-only account, skipped by sync worker |
|
||||||
|
| `1` | IMAP with username/password |
|
||||||
|
| `2` | POP3 with username/password |
|
||||||
|
| `3` | IMAP with OAuth2 |
|
||||||
|
| `4` | POP3 with OAuth2 |
|
||||||
|
|
||||||
|
#### `OAuth2Provider` values
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `0` | None |
|
||||||
|
| `1` | Microsoft (Azure AD / Microsoft 365) |
|
||||||
|
| `2` | Google (Gmail / Google Workspace) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider-specific OAuth2 Configuration
|
||||||
|
|
||||||
|
### Google (Gmail / Google Workspace)
|
||||||
|
|
||||||
|
Google uses **user-delegated OAuth2** (authorization code flow). A one-time interactive authorization
|
||||||
|
is required to obtain a refresh token. The refresh token is then stored in the database and reused
|
||||||
|
automatically for all subsequent operations.
|
||||||
|
|
||||||
|
> The refresh token survives application restarts. It will not be overwritten by the seed process
|
||||||
|
> unless `OAuth2RefreshToken` is explicitly set to a non-empty value in `appsettings.Secrets.json`.
|
||||||
|
|
||||||
|
#### Required fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"UseOAuth2": true,
|
||||||
|
"OAuth2ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||||
|
"OAuth2ClientSecret": "GOCSPX-YOUR_CLIENT_SECRET",
|
||||||
|
"OAuth2RefreshToken": "",
|
||||||
|
"OAuth2TenantId": "",
|
||||||
|
"OAuth2Provider": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Google Cloud Console setup (one-time)
|
||||||
|
|
||||||
|
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create or select a project.
|
||||||
|
2. Enable the **Gmail API** under *APIs & Services ? Library*.
|
||||||
|
3. Go to *APIs & Services ? Credentials* ? **+ Create Credentials** ? **OAuth 2.0 Client ID**:
|
||||||
|
- Application type: **Web application**
|
||||||
|
- **Authorized redirect URIs**: add `https://YOUR_HOST/api/oauth2/google/callback`
|
||||||
|
(e.g. `https://localhost:7261/api/oauth2/google/callback` for local development)
|
||||||
|
4. Go to *APIs & Services ? OAuth consent screen*:
|
||||||
|
- Add the Gmail account under **Test users** (required while app is in Testing mode).
|
||||||
|
|
||||||
|
#### Obtaining the refresh token via the built-in authorization endpoint
|
||||||
|
|
||||||
|
The application provides a built-in OAuth2 flow — no external tools needed.
|
||||||
|
|
||||||
|
1. Open a browser and navigate to:
|
||||||
|
```
|
||||||
|
GET /api/oauth2/google/authorize/{accountId}
|
||||||
|
```
|
||||||
|
Example: `https://localhost:7261/api/oauth2/google/authorize/3`
|
||||||
|
|
||||||
|
2. You will be redirected to Google's consent screen. If you see **"Google hasn't verified this app"**,
|
||||||
|
click **Continue** — this is expected while the app is in Testing mode.
|
||||||
|
|
||||||
|
3. Sign in with the Gmail account and grant access.
|
||||||
|
|
||||||
|
4. Google redirects back to `/api/oauth2/google/callback` automatically.
|
||||||
|
The application exchanges the authorization code for a refresh token and saves it to the database.
|
||||||
|
|
||||||
|
5. A success response is returned:
|
||||||
|
```json
|
||||||
|
{ "success": true, "username": "user@gmail.com", "message": "..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
6. The sync worker and all IMAP/SMTP operations will now work automatically.
|
||||||
|
|
||||||
|
> ?? The refresh token must be re-obtained if `invalid_grant` is returned.
|
||||||
|
> This happens if the token is unused for 6 months or if the user revokes access.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Microsoft 365 / Exchange Online (Azure AD)
|
||||||
|
|
||||||
|
Microsoft uses **application-level OAuth2** (client credentials flow — no user interaction required).
|
||||||
|
Tokens are acquired automatically using the client ID, secret and tenant ID. No authorization endpoint
|
||||||
|
needs to be visited.
|
||||||
|
|
||||||
|
#### Required fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"UseOAuth2": true,
|
||||||
|
"OAuth2ClientId": "YOUR_APP_CLIENT_ID",
|
||||||
|
"OAuth2ClientSecret": "YOUR_APP_CLIENT_SECRET_VALUE",
|
||||||
|
"OAuth2TenantId": "yourorg.onmicrosoft.com",
|
||||||
|
"OAuth2Provider": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Azure Portal setup (one-time)
|
||||||
|
|
||||||
|
1. Go to [portal.azure.com](https://portal.azure.com) ? **Azure Active Directory** ? **App registrations** ? **+ New registration**.
|
||||||
|
2. Go to **Certificates & secrets** ? **+ New client secret** ? copy the **Value** (not the ID).
|
||||||
|
- Set this as `OAuth2ClientSecret`.
|
||||||
|
3. Go to **API permissions** ? **+ Add a permission** ? **APIs my organization uses** ? **Office 365 Exchange Online**:
|
||||||
|
- Add **Application permissions**: `IMAP.AccessAsApp`, `SMTP.SendAsApp`, `POP.AccessAsApp`
|
||||||
|
- Click **Grant admin consent**
|
||||||
|
4. In Exchange Online PowerShell, register the service principal for the mailbox:
|
||||||
|
```powershell
|
||||||
|
New-ServicePrincipal -AppId <ClientId> -ServiceId <ObjectId> -DisplayName "MessagingService"
|
||||||
|
Add-MailboxPermission -Identity "user@yourorg.onmicrosoft.com" -User <ObjectId> -AccessRights FullAccess
|
||||||
|
```
|
||||||
|
5. Set `OAuth2TenantId` to the full domain (e.g. `yourorg.onmicrosoft.com`) or tenant GUID.
|
||||||
|
|
||||||
|
> ?? `OAuth2ClientSecret` must be the **Value** shown at secret creation time, not the Secret ID (GUID).
|
||||||
|
> The value is only visible once — if lost, create a new secret.
|
||||||
|
|
||||||
|
> ?? `OAuth2TenantId` must be a full domain (`yourorg.onmicrosoft.com`), a tenant GUID,
|
||||||
|
> or `common`. Short names like `yourorg` are not valid and will cause `AADSTS900023`.
|
||||||
|
|
||||||
|
> No browser-based authorization is required for Microsoft — the application acquires tokens
|
||||||
|
> automatically on first use and caches them in memory until 5 minutes before expiry.
|
||||||
|
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Email Account Configuration
|
||||||
|
|
||||||
|
Each account is configured under `EmailAccounts.Accounts` in `appsettings.Secrets.json`.
|
||||||
|
|
||||||
|
> Different providers require different configuration fields. See provider-specific sections below.
|
||||||
|
|
||||||
|
### Common fields (all providers)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"Id": 1,
|
||||||
|
"Username": "user@example.com",
|
||||||
|
"Password": "your_password",
|
||||||
|
"SmtpServer": "smtp.example.com",
|
||||||
|
"SmtpPort": 465,
|
||||||
|
"SmtpUseSsl": true,
|
||||||
|
"UseOAuth2": false,
|
||||||
|
"ImapServer": "imap.example.com",
|
||||||
|
"ImapPort": 993,
|
||||||
|
"ImapUseSsl": true,
|
||||||
|
"Pop3Server": "pop.example.com",
|
||||||
|
"Pop3Port": 995,
|
||||||
|
"Pop3UseSsl": true,
|
||||||
|
"IncomingProtocol": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
> `Password` is always retained. When `UseOAuth2 = true`, SMTP/IMAP/POP3 connections use OAuth2 tokens
|
||||||
|
> instead of the password. When `UseOAuth2 = false`, the password is used directly.
|
||||||
|
> The `IncomingProtocol` field independently controls which protocol is used for receiving emails.
|
||||||
|
|
||||||
|
#### `IncomingProtocol` values
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `0` | None — send-only account, skipped by sync worker |
|
||||||
|
| `1` | IMAP with username/password |
|
||||||
|
| `2` | POP3 with username/password |
|
||||||
|
| `3` | IMAP with OAuth2 |
|
||||||
|
| `4` | POP3 with OAuth2 |
|
||||||
|
|
||||||
|
#### `OAuth2Provider` values
|
||||||
|
|
||||||
|
| Value | Meaning |
|
||||||
|
|-------|---------|
|
||||||
|
| `0` | None |
|
||||||
|
| `1` | Microsoft (Azure AD / Microsoft 365) |
|
||||||
|
| `2` | Google (Gmail / Google Workspace) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Provider-specific OAuth2 Configuration
|
||||||
|
|
||||||
|
### Google (Gmail / Google Workspace)
|
||||||
|
|
||||||
|
Google uses **user-delegated OAuth2** (not client credentials). A one-time authorization flow is required to obtain a refresh token.
|
||||||
|
|
||||||
|
#### Required fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"UseOAuth2": true,
|
||||||
|
"OAuth2ClientId": "YOUR_CLIENT_ID.apps.googleusercontent.com",
|
||||||
|
"OAuth2ClientSecret": "GOCSPX-YOUR_CLIENT_SECRET",
|
||||||
|
"OAuth2RefreshToken": "1//04YOUR_REFRESH_TOKEN",
|
||||||
|
"OAuth2TenantId": "",
|
||||||
|
"OAuth2Provider": 2
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Setup — obtaining the refresh token (one-time)
|
||||||
|
|
||||||
|
1. Go to [console.cloud.google.com](https://console.cloud.google.com) and create or select a project.
|
||||||
|
2. Enable the **Gmail API** under *APIs & Services ? Library*.
|
||||||
|
3. Go to *APIs & Services ? Credentials* ? **+ Create Credentials** ? **OAuth 2.0 Client ID**.
|
||||||
|
- Application type: **Web application**
|
||||||
|
- Authorized redirect URIs: `https://developers.google.com/oauthplayground`
|
||||||
|
4. Go to *APIs & Services ? OAuth consent screen*:
|
||||||
|
- Add the Gmail account under **Test users** (required while app is in Testing mode).
|
||||||
|
5. Go to [developers.google.com/oauthplayground](https://developers.google.com/oauthplayground):
|
||||||
|
- Click **?? Settings** ? enable **"Use your own OAuth credentials"**
|
||||||
|
- Enter your **Client ID** and **Client Secret** (from step 3)
|
||||||
|
- Close settings
|
||||||
|
6. In the scope input (Step 1), enter `https://mail.google.com/` ? **Authorize APIs**
|
||||||
|
7. Sign in with the Gmail account ? grant access
|
||||||
|
8. Click **Exchange authorization code for tokens** (Step 2)
|
||||||
|
9. Copy the `refresh_token` value from the response
|
||||||
|
10. Set `OAuth2RefreshToken` in `appsettings.Secrets.json`
|
||||||
|
|
||||||
|
> ?? The refresh token must be obtained using **your own client credentials** in Playground settings.
|
||||||
|
> If obtained with Playground's default credentials, it will not work with your client secret.
|
||||||
|
|
||||||
|
> ?? Google refresh tokens expire if unused for 6 months, or if the user revokes access.
|
||||||
|
> The token must be re-obtained if `invalid_grant` is returned.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Microsoft 365 / Exchange Online (Azure AD)
|
||||||
|
|
||||||
|
Microsoft uses **application-level OAuth2** (client credentials flow — no user interaction required).
|
||||||
|
|
||||||
|
#### Required fields
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"UseOAuth2": true,
|
||||||
|
"OAuth2ClientId": "YOUR_APP_CLIENT_ID",
|
||||||
|
"OAuth2ClientSecret": "YOUR_APP_CLIENT_SECRET_VALUE",
|
||||||
|
"OAuth2TenantId": "yourorg.onmicrosoft.com",
|
||||||
|
"OAuth2Provider": 1
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Setup
|
||||||
|
|
||||||
|
1. Go to [portal.azure.com](https://portal.azure.com) ? **Azure Active Directory** ? **App registrations** ? **+ New registration**.
|
||||||
|
2. Go to **Certificates & secrets** ? **+ New client secret** ? copy the **Value** (not the ID).
|
||||||
|
- Set this as `OAuth2ClientSecret`.
|
||||||
|
3. Go to **API permissions** ? **+ Add a permission** ? **APIs my organization uses** ? **Office 365 Exchange Online**:
|
||||||
|
- Add **Application permissions**: `IMAP.AccessAsApp`, `SMTP.SendAsApp`, `POP.AccessAsApp`
|
||||||
|
- Click **Grant admin consent**
|
||||||
|
4. In Exchange Online PowerShell, register the service principal for the mailbox:
|
||||||
|
```powershell
|
||||||
|
New-ServicePrincipal -AppId <ClientId> -ServiceId <ObjectId> -DisplayName "MessagingService"
|
||||||
|
Add-MailboxPermission -Identity "user@yourorg.onmicrosoft.com" -User <ObjectId> -AccessRights FullAccess
|
||||||
|
```
|
||||||
|
5. Set `OAuth2TenantId` to the full domain (e.g. `yourorg.onmicrosoft.com`) or tenant GUID.
|
||||||
|
|
||||||
|
> ?? `OAuth2ClientSecret` must be the **Value** shown at secret creation time, not the Secret ID (GUID).
|
||||||
|
> The value is only visible once — if lost, create a new secret.
|
||||||
|
|
||||||
|
> ?? `OAuth2TenantId` must be a full domain (`yourorg.onmicrosoft.com`), a tenant GUID,
|
||||||
|
> or `common`. Short names like `yourorg` are not valid and will cause `AADSTS900023`.
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
BIN
assets/icon.png
Normal file
BIN
assets/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.1 KiB |
1
legacy
Submodule
1
legacy
Submodule
Submodule legacy added at e59b936181
@@ -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 +0,0 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk.Web">
|
|
||||||
|
|
||||||
<PropertyGroup>
|
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
|
||||||
<Nullable>enable</Nullable>
|
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
|
||||||
<UserSecretsId>dotnet-DigitalData.MessagingService.Service-e7ef6a9a-436f-48a1-b4f6-ec9381c8cb47</UserSecretsId>
|
|
||||||
</PropertyGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<PackageReference Include="Serilog.AspNetCore" Version="10.0.0" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
|
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
|
|
||||||
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
|
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
|
||||||
<PackageReference Include="System.Security.Cryptography.Xml" Version="10.0.10" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<Folder Include="Properties\" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
<ItemGroup>
|
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
|
||||||
<ProjectReference Include="..\DigitalData.MessagingService.Infrastructure\DigitalData.MessagingService.Infrastructure.csproj" />
|
|
||||||
</ItemGroup>
|
|
||||||
|
|
||||||
</Project>
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
using System.Net;
|
|
||||||
using System.Text.Json;
|
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.API.Middleware;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Global exception handling middleware
|
|
||||||
/// </summary>
|
|
||||||
public class ExceptionHandlingMiddleware
|
|
||||||
{
|
|
||||||
private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
|
|
||||||
{
|
|
||||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
|
|
||||||
};
|
|
||||||
|
|
||||||
private readonly RequestDelegate _next;
|
|
||||||
private readonly ILogger<ExceptionHandlingMiddleware> _logger;
|
|
||||||
|
|
||||||
public ExceptionHandlingMiddleware(
|
|
||||||
RequestDelegate next,
|
|
||||||
ILogger<ExceptionHandlingMiddleware> logger)
|
|
||||||
{
|
|
||||||
_next = next;
|
|
||||||
_logger = logger;
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task InvokeAsync(HttpContext context)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _next(context);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
await HandleExceptionAsync(context, ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleExceptionAsync(HttpContext context, Exception exception)
|
|
||||||
{
|
|
||||||
context.Response.ContentType = "application/json";
|
|
||||||
|
|
||||||
var (statusCode, message) = exception switch
|
|
||||||
{
|
|
||||||
NotFoundException notFoundEx =>
|
|
||||||
(HttpStatusCode.NotFound, notFoundEx.Message),
|
|
||||||
|
|
||||||
AuthenticationFailedException authEx =>
|
|
||||||
(HttpStatusCode.Unauthorized, authEx.Message),
|
|
||||||
|
|
||||||
DmsNotAvailableException dmsEx =>
|
|
||||||
(HttpStatusCode.ServiceUnavailable, dmsEx.Message),
|
|
||||||
|
|
||||||
InvalidPdfException pdfEx =>
|
|
||||||
(HttpStatusCode.BadRequest, pdfEx.Message),
|
|
||||||
|
|
||||||
FluentValidation.ValidationException validationEx =>
|
|
||||||
(HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
|
|
||||||
|
|
||||||
_ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
|
|
||||||
};
|
|
||||||
|
|
||||||
context.Response.StatusCode = (int)statusCode;
|
|
||||||
|
|
||||||
// Log the exception
|
|
||||||
if (statusCode == HttpStatusCode.InternalServerError)
|
|
||||||
{
|
|
||||||
_logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
|
|
||||||
statusCode, message);
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = new
|
|
||||||
{
|
|
||||||
StatusCode = (int)statusCode,
|
|
||||||
Message = message,
|
|
||||||
DetailedMessage = statusCode == HttpStatusCode.InternalServerError
|
|
||||||
? exception.Message
|
|
||||||
: null,
|
|
||||||
Timestamp = DateTime.Now
|
|
||||||
};
|
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
|
|
||||||
|
|
||||||
await context.Response.WriteAsync(json);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string FormatValidationErrors(FluentValidation.ValidationException exception)
|
|
||||||
{
|
|
||||||
var errors = exception.Errors
|
|
||||||
.Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return string.Join("; ", errors);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
using DigitalData.MessagingService.API.Middleware;
|
|
||||||
using DigitalData.MessagingService.Application;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
|
||||||
using DigitalData.MessagingService.Infrastructure;
|
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
// Configure Serilog early
|
|
||||||
Log.Logger = new LoggerConfiguration()
|
|
||||||
.WriteTo.Console()
|
|
||||||
.WriteTo.File(
|
|
||||||
path: "logs/emailprofiler-.log",
|
|
||||||
rollingInterval: RollingInterval.Day,
|
|
||||||
retainedFileCountLimit: 30,
|
|
||||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
|
|
||||||
.CreateBootstrapLogger();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
Log.Information("Starting MessagingService API");
|
|
||||||
|
|
||||||
var builder = WebApplication.CreateBuilder(args);
|
|
||||||
|
|
||||||
// Use Serilog for logging
|
|
||||||
builder.Host.UseSerilog((context, services, configuration) => configuration
|
|
||||||
.ReadFrom.Configuration(context.Configuration)
|
|
||||||
.ReadFrom.Services(services)
|
|
||||||
.Enrich.FromLogContext()
|
|
||||||
.WriteTo.Console()
|
|
||||||
.WriteTo.File(
|
|
||||||
path: "logs/emailprofiler-.log",
|
|
||||||
rollingInterval: RollingInterval.Day,
|
|
||||||
retainedFileCountLimit: 30,
|
|
||||||
outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"));
|
|
||||||
|
|
||||||
// Add appsettings.Secrets.json for sensitive configuration (not committed to git)
|
|
||||||
builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
|
|
||||||
|
|
||||||
// Register Application layer (MediatR, AutoMapper, FluentValidation)
|
|
||||||
builder.Services.AddApplicationServices(builder.Configuration);
|
|
||||||
|
|
||||||
// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
|
|
||||||
builder.Services.AddInfrastructure(builder.Configuration);
|
|
||||||
|
|
||||||
// Register EmailAccount configuration (IOptions<EmailAccountDto>)
|
|
||||||
builder.Services.Configure<EmailAccountDto>(
|
|
||||||
builder.Configuration.GetSection("EmailAccount"));
|
|
||||||
|
|
||||||
builder.Services.AddControllers();
|
|
||||||
|
|
||||||
builder.Services.AddEndpointsApiExplorer();
|
|
||||||
builder.Services.AddSwaggerGen();
|
|
||||||
|
|
||||||
var app = builder.Build();
|
|
||||||
|
|
||||||
// Add global exception handling middleware
|
|
||||||
app.UseMiddleware<ExceptionHandlingMiddleware>();
|
|
||||||
|
|
||||||
// Add Serilog request logging
|
|
||||||
app.UseSerilogRequestLogging();
|
|
||||||
|
|
||||||
// Configure the HTTP request pipeline.
|
|
||||||
if (app.Environment.IsDevelopment())
|
|
||||||
{
|
|
||||||
app.UseSwagger();
|
|
||||||
app.UseSwaggerUI();
|
|
||||||
}
|
|
||||||
|
|
||||||
app.UseHttpsRedirection();
|
|
||||||
|
|
||||||
app.UseAuthorization();
|
|
||||||
|
|
||||||
app.MapControllers();
|
|
||||||
|
|
||||||
Log.Information("MessagingService API started successfully");
|
|
||||||
|
|
||||||
app.Run();
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Log.Fatal(ex, "MessagingService API failed to start");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
Log.CloseAndFlush();
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
{
|
|
||||||
"Logging": {
|
|
||||||
"LogLevel": {
|
|
||||||
"Default": "Information",
|
|
||||||
"Microsoft.AspNetCore": "Warning"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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,28 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Application.Common.Events;
|
|
||||||
|
|
||||||
public class OutgoingEmailEvent
|
|
||||||
{
|
|
||||||
public required Guid Id { get; init; }
|
|
||||||
|
|
||||||
/// <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;
|
|
||||||
|
|
||||||
public DateTime QueuedAt { get; init; }
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Events;
|
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email queue interface for outgoing emails.
|
|
||||||
/// </summary>
|
|
||||||
public interface IOutgoingEmailQueue
|
|
||||||
{
|
|
||||||
Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Events;
|
|
||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// AutoMapper profile for Emails
|
|
||||||
/// </summary>
|
|
||||||
public class EmailMappingProfile : Profile
|
|
||||||
{
|
|
||||||
public EmailMappingProfile()
|
|
||||||
{
|
|
||||||
// SendEmailCommand -> OutgoingEmailEvent
|
|
||||||
CreateMap<SendEmailCommand, OutgoingEmailEvent>()
|
|
||||||
.ForMember(dest => dest.Id, opt => opt.MapFrom(_ => Guid.NewGuid()))
|
|
||||||
.ForMember(dest => dest.QueuedAt, opt => opt.MapFrom(_ => DateTime.Now));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
using AutoMapper;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Events;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
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(IOutgoingEmailQueue EmailQueue, IMapper Mapper) : IRequestHandler<SendEmailCommand, OutgoingEmailEvent>
|
|
||||||
{
|
|
||||||
public async Task<OutgoingEmailEvent> Handle(SendEmailCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var outgoingEmailEvent = Mapper.Map<OutgoingEmailEvent>(request);
|
|
||||||
|
|
||||||
// Enqueue to RabbitMQ
|
|
||||||
await EmailQueue.EnqueueAsync(outgoingEmailEvent, cancellationToken);
|
|
||||||
return outgoingEmailEvent;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
|
||||||
using FluentValidation;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Validator for SendEmailCommand
|
|
||||||
/// </summary>
|
|
||||||
public class SendEmailCommandValidator : AbstractValidator<SendEmailCommand>
|
|
||||||
{
|
|
||||||
public SendEmailCommandValidator()
|
|
||||||
{
|
|
||||||
RuleFor(x => x.Recipient)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Recipient is required")
|
|
||||||
.MaximumLength(200)
|
|
||||||
.WithMessage("Recipient must not exceed 200 characters")
|
|
||||||
.EmailAddress()
|
|
||||||
.WithMessage("Recipient must be a valid email address");
|
|
||||||
|
|
||||||
RuleFor(x => x.Subject)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Subject is required")
|
|
||||||
.MaximumLength(500)
|
|
||||||
.WithMessage("Subject must not exceed 500 characters");
|
|
||||||
|
|
||||||
RuleFor(x => x.Body)
|
|
||||||
.NotEmpty()
|
|
||||||
.WithMessage("Body is required");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Common;
|
|
||||||
|
|
||||||
public abstract class BaseEntity
|
|
||||||
{
|
|
||||||
public DateTime? CreatedDate { get; set; }
|
|
||||||
public string? CreatedBy { get; set; }
|
|
||||||
public DateTime? ModifiedDate { get; set; }
|
|
||||||
public string? ModifiedBy { get; set; }
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Domain-wide constants
|
|
||||||
/// </summary>
|
|
||||||
public static class DomainConstants
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Email processing constants
|
|
||||||
/// </summary>
|
|
||||||
public static class Email
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Maximum number of retry attempts for failed email sending
|
|
||||||
/// After this limit, email will be moved to Dead Letter Queue (DLQ)
|
|
||||||
/// </summary>
|
|
||||||
public const int MaxRetryCount = 3;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Marker interface for aggregate roots in DDD
|
|
||||||
/// </summary>
|
|
||||||
public interface IAggregateRoot
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,43 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Common;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Base class for value objects that implement equality by value
|
|
||||||
/// </summary>
|
|
||||||
public abstract class ValueObject
|
|
||||||
{
|
|
||||||
protected abstract IEnumerable<object> GetEqualityComponents();
|
|
||||||
|
|
||||||
public override bool Equals(object? obj)
|
|
||||||
{
|
|
||||||
if (obj == null || obj.GetType() != GetType())
|
|
||||||
{
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var other = (ValueObject)obj;
|
|
||||||
return GetEqualityComponents().SequenceEqual(other.GetEqualityComponents());
|
|
||||||
}
|
|
||||||
|
|
||||||
public override int GetHashCode()
|
|
||||||
{
|
|
||||||
return GetEqualityComponents()
|
|
||||||
.Select(x => x?.GetHashCode() ?? 0)
|
|
||||||
.Aggregate((x, y) => x ^ y);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool operator ==(ValueObject? left, ValueObject? right)
|
|
||||||
{
|
|
||||||
if (left is null && right is null)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
if (left is null || right is null)
|
|
||||||
return false;
|
|
||||||
|
|
||||||
return left.Equals(right);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static bool operator !=(ValueObject? left, ValueObject? right)
|
|
||||||
{
|
|
||||||
return !(left == right);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,9 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
public enum AttachmentStatus
|
|
||||||
{
|
|
||||||
Pending = 1,
|
|
||||||
Valid = 2,
|
|
||||||
Corrupt = 3,
|
|
||||||
Skipped = 4
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
public enum AuthenticationType
|
|
||||||
{
|
|
||||||
UsernamePassword = 1,
|
|
||||||
OAuth2 = 2
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
public enum EmailStatus
|
|
||||||
{
|
|
||||||
Pending = 1,
|
|
||||||
Processing = 2,
|
|
||||||
Processed = 3,
|
|
||||||
Failed = 4,
|
|
||||||
PartiallyProcessed = 5,
|
|
||||||
Rejected = 6
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
public enum ErrorCode
|
|
||||||
{
|
|
||||||
None = 0,
|
|
||||||
NoAttachments = 10001,
|
|
||||||
SenderValidationFailed = 10002,
|
|
||||||
EmbeddedFileAttachmentCorrupt = 10003,
|
|
||||||
NormalFileAttachmentCorrupt = 10004,
|
|
||||||
PdfStructureInvalid = 10005,
|
|
||||||
ImapConnectionFailed = 10006,
|
|
||||||
WindreamImportFailed = 10007,
|
|
||||||
DiskSpaceInsufficient = 10008,
|
|
||||||
DuplicateMessageId = 10009,
|
|
||||||
AttachmentExtractionFailed = 10010
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
public enum ProcessType
|
|
||||||
{
|
|
||||||
ProcessManager = 1, // Easy Approval workflow
|
|
||||||
AttachmentSniffer = 2, // General attachment extraction
|
|
||||||
ZugFeRDParser = 3 // Electronic invoice processing
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
public class AttachmentProcessingException : DomainException
|
|
||||||
{
|
|
||||||
public AttachmentProcessingException(ErrorCode errorCode, string message) : base(message, errorCode)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public AttachmentProcessingException(ErrorCode errorCode, string message, Exception innerException)
|
|
||||||
: base(message, errorCode)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exception thrown when OAuth2 authentication fails.
|
|
||||||
/// </summary>
|
|
||||||
public class AuthenticationFailedException : Exception
|
|
||||||
{
|
|
||||||
public AuthenticationFailedException(string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public AuthenticationFailedException(string message, Exception innerException)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exception thrown when DMS (windream) is not available or not configured properly.
|
|
||||||
/// </summary>
|
|
||||||
public class DmsNotAvailableException : Exception
|
|
||||||
{
|
|
||||||
public DmsNotAvailableException()
|
|
||||||
: base("DMS service is not available. windream COM components may not be registered.")
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DmsNotAvailableException(string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DmsNotAvailableException(string message, Exception innerException)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
public class DomainException : Exception
|
|
||||||
{
|
|
||||||
public ErrorCode? ErrorCode { get; }
|
|
||||||
|
|
||||||
public DomainException(string message) : base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public DomainException(string message, ErrorCode errorCode) : base(message)
|
|
||||||
{
|
|
||||||
ErrorCode = errorCode;
|
|
||||||
}
|
|
||||||
|
|
||||||
public DomainException(string message, Exception innerException) : base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exception thrown when a PDF file is invalid or corrupted.
|
|
||||||
/// </summary>
|
|
||||||
public class InvalidPdfException : Exception
|
|
||||||
{
|
|
||||||
public InvalidPdfException(string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public InvalidPdfException(string message, Exception innerException)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Exception thrown when a requested entity is not found.
|
|
||||||
/// </summary>
|
|
||||||
public class NotFoundException : Exception
|
|
||||||
{
|
|
||||||
public NotFoundException(string entityName, object key)
|
|
||||||
: base($"{entityName} with key '{key}' was not found.")
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public NotFoundException(string message)
|
|
||||||
: base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public NotFoundException(string message, Exception innerException)
|
|
||||||
: base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Domain.Enums;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
public class ValidationException : DomainException
|
|
||||||
{
|
|
||||||
public ValidationException(string message) : base(message)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValidationException(string message, ErrorCode errorCode) : base(message, errorCode)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
|
|
||||||
public ValidationException(string message, Exception innerException) : base(message, innerException)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Domain.ValueObjects;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.Services;
|
|
||||||
|
|
||||||
public interface IMessageIdGenerator
|
|
||||||
{
|
|
||||||
MessageId Generate(string originalMessageId, string sender, DateTime date, string subject);
|
|
||||||
List<string> GenerateFallbackHashes(MessageId messageId);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MessageIdGenerator : IMessageIdGenerator
|
|
||||||
{
|
|
||||||
public MessageId Generate(string originalMessageId, string sender, DateTime date, string subject)
|
|
||||||
{
|
|
||||||
return MessageId.Create(originalMessageId, sender, date, subject);
|
|
||||||
}
|
|
||||||
|
|
||||||
public List<string> GenerateFallbackHashes(MessageId messageId)
|
|
||||||
{
|
|
||||||
// Legacy behavior: 10 variations for duplicate detection
|
|
||||||
var hashes = new List<string> { messageId.Hash };
|
|
||||||
|
|
||||||
// Add variations (this is simplified - legacy had 10 variations)
|
|
||||||
// TODO: Implement exact legacy fallback algorithm if needed
|
|
||||||
|
|
||||||
return hashes;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,45 +0,0 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
|
||||||
using DigitalData.MessagingService.Domain.Common;
|
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.ValueObjects;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Value object representing an email address with validation
|
|
||||||
/// </summary>
|
|
||||||
public class EmailAddress : ValueObject
|
|
||||||
{
|
|
||||||
public string Value { get; private set; }
|
|
||||||
public string Domain { get; private set; }
|
|
||||||
public string LocalPart { get; private set; }
|
|
||||||
|
|
||||||
private EmailAddress(string value)
|
|
||||||
{
|
|
||||||
Value = value;
|
|
||||||
var parts = value.Split('@');
|
|
||||||
LocalPart = parts[0];
|
|
||||||
Domain = parts[1];
|
|
||||||
}
|
|
||||||
|
|
||||||
public static EmailAddress Create(string email)
|
|
||||||
{
|
|
||||||
if (!IsValid(email))
|
|
||||||
throw new DomainException($"Invalid email address: {email}");
|
|
||||||
|
|
||||||
return new EmailAddress(email.ToLowerInvariant());
|
|
||||||
}
|
|
||||||
|
|
||||||
private static bool IsValid(string email)
|
|
||||||
{
|
|
||||||
return !string.IsNullOrWhiteSpace(email) &&
|
|
||||||
email.Contains('@') &&
|
|
||||||
new EmailAddressAttribute().IsValid(email);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override IEnumerable<object> GetEqualityComponents()
|
|
||||||
{
|
|
||||||
yield return Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
public override string ToString() => Value;
|
|
||||||
}
|
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
using System.Security.Cryptography;
|
|
||||||
using System.Text;
|
|
||||||
using DigitalData.MessagingService.Domain.Common;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Domain.ValueObjects;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Value object representing a unique message identifier with hash generation
|
|
||||||
/// Uses the same algorithm as legacy system for compatibility
|
|
||||||
/// </summary>
|
|
||||||
public class MessageId : ValueObject
|
|
||||||
{
|
|
||||||
public string Value { get; private set; }
|
|
||||||
public string Hash { get; private set; }
|
|
||||||
|
|
||||||
private MessageId(string value)
|
|
||||||
{
|
|
||||||
Value = value;
|
|
||||||
Hash = GenerateHash(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static MessageId Create(string originalMessageId, string sender, DateTime date, string subject)
|
|
||||||
{
|
|
||||||
var combined = $"{originalMessageId}|{sender}|{date:yyyyMMddHHmmss}|{subject}";
|
|
||||||
return new MessageId(combined);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string GenerateHash(string input)
|
|
||||||
{
|
|
||||||
// Same algorithm as legacy: SHA256 hash
|
|
||||||
using var sha256 = SHA256.Create();
|
|
||||||
var bytes = Encoding.UTF8.GetBytes(input);
|
|
||||||
var hash = sha256.ComputeHash(bytes);
|
|
||||||
return Convert.ToBase64String(hash);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected override IEnumerable<object> GetEqualityComponents()
|
|
||||||
{
|
|
||||||
yield return Value;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Messaging;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Services;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
|
||||||
using Microsoft.AspNetCore.DataProtection;
|
|
||||||
using Microsoft.Extensions.Configuration;
|
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dependency injection configuration for Infrastructure layer
|
|
||||||
/// </summary>
|
|
||||||
public static class DependencyInjection
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Adds Infrastructure layer services to the DI container
|
|
||||||
/// </summary>
|
|
||||||
public static IServiceCollection AddInfrastructure(
|
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration)
|
|
||||||
{
|
|
||||||
// --- External Services ---
|
|
||||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
|
||||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
|
||||||
|
|
||||||
// PDF Processing Service (using DevExpress.Pdf)
|
|
||||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
|
||||||
|
|
||||||
// Encryption Service (using Data Protection API - Singleton, thread-safe)
|
|
||||||
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
|
||||||
|
|
||||||
// --- Email Queue (RabbitMQ) ---
|
|
||||||
services.AddSingleton<IOutgoingEmailQueue, OutgoingEmailQueue>();
|
|
||||||
|
|
||||||
// --- RabbitMQ Configuration ---
|
|
||||||
services.Configure<RabbitMqConfiguration>(
|
|
||||||
configuration.GetSection(RabbitMqConfiguration.SectionName));
|
|
||||||
|
|
||||||
// --- Data Protection (for encryption) ---
|
|
||||||
services.AddDataProtection()
|
|
||||||
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\ProgramData\EmailService\Keys"))
|
|
||||||
.SetApplicationName("MessagingService");
|
|
||||||
|
|
||||||
// Register Background Workers
|
|
||||||
services.AddHostedService<AsyncInitWorker>();
|
|
||||||
|
|
||||||
return services;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
namespace DigitalData.MessagingService.Infrastructure.Messaging;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration for RabbitMQ connection
|
|
||||||
/// </summary>
|
|
||||||
public class RabbitMqConfiguration
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration section name in appsettings.json
|
|
||||||
/// </summary>
|
|
||||||
public const string SectionName = "RabbitMQ";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RabbitMQ server hostname
|
|
||||||
/// </summary>
|
|
||||||
public string HostName { get; set; } = "localhost";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RabbitMQ AMQP port (default: 5672)
|
|
||||||
/// </summary>
|
|
||||||
public int Port { get; set; } = 5672;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RabbitMQ username
|
|
||||||
/// </summary>
|
|
||||||
public string UserName { get; set; } = "guest";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RabbitMQ password
|
|
||||||
/// </summary>
|
|
||||||
public string Password { get; set; } = "guest";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Virtual host (default: /)
|
|
||||||
/// </summary>
|
|
||||||
public string VirtualHost { get; set; } = "/";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Enable automatic recovery on connection failure
|
|
||||||
/// </summary>
|
|
||||||
public bool AutomaticRecoveryEnabled { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Network recovery interval in seconds
|
|
||||||
/// </summary>
|
|
||||||
public int NetworkRecoveryIntervalSeconds { get; set; } = 10;
|
|
||||||
|
|
||||||
public string QueueName { get; set; } = null!;
|
|
||||||
public string ExchangeName { get; set; } = null!;
|
|
||||||
public string RoutingKey { get; set; } = null!;
|
|
||||||
public string DlqQueueName { get; set; } = null!;
|
|
||||||
public string DlqExchangeName { get; set; } = null!;
|
|
||||||
public string DlqRoutingKey { get; set; } = null!;
|
|
||||||
}
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Entity Framework Core DbContext for MessagingService.
|
|
||||||
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
|
|
||||||
/// </summary>
|
|
||||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
|
||||||
{
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using System.Text.Json;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Messaging;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using RabbitMQ.Client;
|
|
||||||
using RabbitMQ.Client.Events;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Events;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
|
||||||
/// Provides message persistence, scalability, and reliability.
|
|
||||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class OutgoingEmailQueue(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailQueue> Logger, IEmailService EmailService) : IOutgoingEmailQueue, IAsyncDisposable
|
|
||||||
{
|
|
||||||
private readonly RabbitMqConfiguration _config = config.Value;
|
|
||||||
private IConnection? _connection = null;
|
|
||||||
private IChannel? _publishChannel = null; // Dedicated channel for publishing
|
|
||||||
private IChannel? _consumeChannel = null; // Dedicated channel for consuming
|
|
||||||
private readonly CancellationTokenSource _consumerCts = new(); // Independent lifetime from InitAsync token
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
|
||||||
/// Called lazily on first use via EnsureInitializedAsync.
|
|
||||||
/// </summary>
|
|
||||||
public async Task InitAsync(CancellationToken stoppingToken = default)
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Initializing RabbitMQ connection and queues...");
|
|
||||||
|
|
||||||
var factory = new ConnectionFactory
|
|
||||||
{
|
|
||||||
HostName = _config.HostName,
|
|
||||||
Port = _config.Port,
|
|
||||||
UserName = _config.UserName,
|
|
||||||
Password = _config.Password,
|
|
||||||
VirtualHost = _config.VirtualHost,
|
|
||||||
AutomaticRecoveryEnabled = _config.AutomaticRecoveryEnabled,
|
|
||||||
NetworkRecoveryInterval = TimeSpan.FromSeconds(_config.NetworkRecoveryIntervalSeconds)
|
|
||||||
};
|
|
||||||
|
|
||||||
_connection = await factory.CreateConnectionAsync(stoppingToken);
|
|
||||||
|
|
||||||
// Separate channels: IChannel is not thread-safe; one per role is best practice
|
|
||||||
_publishChannel = await _connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
|
||||||
_consumeChannel = await _connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Topology declaration can use either channel; use publish channel here
|
|
||||||
// Declare Dead Letter Queue (DLQ) exchange
|
|
||||||
await _publishChannel.ExchangeDeclareAsync(exchange: _config.DlqExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Declare Dead Letter Queue (DLQ)
|
|
||||||
await _publishChannel.QueueDeclareAsync(queue: _config.DlqQueueName, durable: true, exclusive: false, autoDelete: false, arguments: null, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Bind DLQ to DLQ exchange
|
|
||||||
await _publishChannel.QueueBindAsync(queue: _config.DlqQueueName, exchange: _config.DlqExchangeName, routingKey: _config.DlqRoutingKey, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Declare main exchange (Direct type for routing)
|
|
||||||
await _publishChannel.ExchangeDeclareAsync(exchange: _config.ExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Declare main queue (durable for persistence) with DLQ arguments
|
|
||||||
var queueArgs = new Dictionary<string, object?>
|
|
||||||
{
|
|
||||||
{ "x-dead-letter-exchange", _config.DlqExchangeName },
|
|
||||||
{ "x-dead-letter-routing-key", _config.DlqRoutingKey }
|
|
||||||
};
|
|
||||||
|
|
||||||
await _publishChannel.QueueDeclareAsync(queue: _config.QueueName, durable: true, exclusive: false, autoDelete: false, arguments: queueArgs, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Bind main queue to exchange with routing key
|
|
||||||
await _publishChannel.QueueBindAsync(queue: _config.QueueName, exchange: _config.ExchangeName, routingKey: _config.RoutingKey, cancellationToken: stoppingToken);
|
|
||||||
|
|
||||||
// Consumer uses its own CancellationToken independent of the startup token,
|
|
||||||
// so it keeps running after InitAsync completes or its token is cancelled.
|
|
||||||
// Link stoppingToken so the consumer stops when the host stops.
|
|
||||||
stoppingToken.Register(() => _consumerCts.Cancel());
|
|
||||||
await StartConsumerAsync(_consumeChannel, _consumerCts.Token);
|
|
||||||
|
|
||||||
Logger.LogInformation("RabbitMQ initialized successfully: Queue={QueueName}, DLQ={DlqQueueName}", _config.QueueName, _config.DlqQueueName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var json = JsonSerializer.Serialize(outgoingEmailEvent);
|
|
||||||
var body = Encoding.UTF8.GetBytes(json);
|
|
||||||
|
|
||||||
var properties = new BasicProperties
|
|
||||||
{
|
|
||||||
Persistent = true, // Message persistence
|
|
||||||
ContentType = "application/json",
|
|
||||||
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
|
||||||
};
|
|
||||||
|
|
||||||
await _publishChannel!.BasicPublishAsync(
|
|
||||||
exchange: _config.ExchangeName,
|
|
||||||
routingKey: _config.RoutingKey,
|
|
||||||
mandatory: false,
|
|
||||||
basicProperties: properties,
|
|
||||||
body: body,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
var queueInfo = await _publishChannel!.QueueDeclarePassiveAsync(_config.QueueName, cancellationToken);
|
|
||||||
return (int)queueInfo.MessageCount;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Start event-driven consumer that processes messages as they arrive
|
|
||||||
/// </summary>
|
|
||||||
private async Task StartConsumerAsync(IChannel consumeChannel, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var consumer = new AsyncEventingBasicConsumer(consumeChannel);
|
|
||||||
|
|
||||||
consumer.ReceivedAsync += async (sender, args) =>
|
|
||||||
{
|
|
||||||
OutgoingEmailEvent? oMailEvent = null;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
|
||||||
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
|
|
||||||
|
|
||||||
if (oMailEvent is not null)
|
|
||||||
{
|
|
||||||
Logger.LogDebug("Received email message: To={To}, Subject={Subject}", oMailEvent.Recipient, oMailEvent.Subject);
|
|
||||||
|
|
||||||
Logger.LogInformation("Processing outgoing email: To={To}, Subject={Subject}",
|
|
||||||
oMailEvent.Recipient, oMailEvent.Subject);
|
|
||||||
|
|
||||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
|
||||||
await EmailService.SendEmailAsync(
|
|
||||||
oMailEvent.Recipient,
|
|
||||||
oMailEvent.Subject,
|
|
||||||
oMailEvent.Body,
|
|
||||||
isHtml: oMailEvent.IsHtml);
|
|
||||||
|
|
||||||
Logger.LogInformation("Email sent successfully: To={To}, Subject={Subject}",
|
|
||||||
oMailEvent.Recipient, oMailEvent.Subject);
|
|
||||||
|
|
||||||
// Acknowledge message after successful processing
|
|
||||||
await consumeChannel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
|
||||||
Logger.LogDebug("Message acknowledged: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
Logger.LogWarning("Failed to deserialize email message: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
|
||||||
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // Don't requeue invalid messages
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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);
|
|
||||||
|
|
||||||
// TODO: Error Reporting Strategy
|
|
||||||
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
|
||||||
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
|
|
||||||
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
|
||||||
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
|
||||||
//
|
|
||||||
// Option 2: Database Table (TBEMLP_ERROR_LOG)
|
|
||||||
// - Columns: ERROR_ID, OUTBOX_ID, ERROR_MESSAGE, STACK_TRACE, ERROR_DATE
|
|
||||||
// - Insert via IErrorLogRepository.CreateAsync(errorLog)
|
|
||||||
//
|
|
||||||
// Option 3: External Monitoring Service
|
|
||||||
// - Sentry: SentrySdk.CaptureException(ex)
|
|
||||||
// - Application Insights: _telemetryClient.TrackException(ex)
|
|
||||||
// - Elasticsearch: _elasticClient.IndexDocument(errorLog)
|
|
||||||
//
|
|
||||||
// Recommended: Option 1 (RabbitMQ Error Queue) + Option 2 (DB persistence)
|
|
||||||
// - Fast async error logging (non-blocking)
|
|
||||||
// - Persistent storage for audit
|
|
||||||
// - Real-time alerting via monitoring worker
|
|
||||||
|
|
||||||
// NO RETRY - All failures move directly to DLQ
|
|
||||||
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Start consuming messages (event-driven, non-blocking)
|
|
||||||
await consumeChannel.BasicConsumeAsync(
|
|
||||||
queue: _config.QueueName,
|
|
||||||
autoAck: false,
|
|
||||||
consumer: consumer,
|
|
||||||
cancellationToken: cancellationToken);
|
|
||||||
|
|
||||||
Logger.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
await _consumerCts.CancelAsync();
|
|
||||||
_consumerCts.Dispose();
|
|
||||||
|
|
||||||
if (_consumeChannel is not null)
|
|
||||||
{
|
|
||||||
await _consumeChannel.CloseAsync();
|
|
||||||
await _consumeChannel.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_publishChannel is not null)
|
|
||||||
{
|
|
||||||
await _publishChannel.CloseAsync();
|
|
||||||
await _publishChannel.DisposeAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_connection is not null)
|
|
||||||
{
|
|
||||||
await _connection.CloseAsync();
|
|
||||||
await _connection.DisposeAsync();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
|
||||||
using Microsoft.Extensions.Hosting;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// A hosted background service responsible for initializing the outgoing email queue consumer.
|
|
||||||
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
|
||||||
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
|
||||||
/// </summary>
|
|
||||||
public class AsyncInitWorker(IOutgoingEmailQueue EmailQueue, ILogger<AsyncInitWorker> Logger) : BackgroundService
|
|
||||||
{
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
|
||||||
{
|
|
||||||
Logger.LogInformation("Outgoing email queue worker is starting. Initializing event-driven RabbitMQ consumer.");
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Initialize the RabbitMQ push-based consumer. This call is non-blocking;
|
|
||||||
// message processing is handled asynchronously via registered event callbacks.
|
|
||||||
if (EmailQueue is OutgoingEmailQueue outgoingEmailQueue)
|
|
||||||
await outgoingEmailQueue.InitAsync(stoppingToken);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
Logger.LogError(ex, "A critical error occurred while initializing the outgoing email queue consumer. The worker cannot proceed.");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
using System.Text;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
|
||||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
|
||||||
using DigitalData.MessagingService.Domain.Exceptions;
|
|
||||||
using Limilabs.Client.SMTP;
|
|
||||||
using Limilabs.Mail;
|
|
||||||
using Limilabs.Mail.Headers;
|
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
|
||||||
/// Commercial-grade library with superior Exchange support.
|
|
||||||
/// SMTP configuration is injected via IOptions<EmailAccountDto> from appsettings.json.
|
|
||||||
/// </summary>
|
|
||||||
public class LimilabsEmailService(
|
|
||||||
IEncryptionService encryptionService,
|
|
||||||
IOptions<EmailAccountDto> smtpConfig) : IEmailService
|
|
||||||
{
|
|
||||||
private readonly EmailAccountDto _smtpAccount = smtpConfig.Value;
|
|
||||||
|
|
||||||
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
|
||||||
static LimilabsEmailService()
|
|
||||||
{
|
|
||||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
using var smtp = new Smtp();
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await ConnectAndAuthenticateSmtpAsync(smtp);
|
|
||||||
|
|
||||||
var builder = new MailBuilder();
|
|
||||||
builder.From.Add(new MailBox(_smtpAccount.Username));
|
|
||||||
builder.To.Add(new MailBox(to));
|
|
||||||
builder.Subject = subject;
|
|
||||||
|
|
||||||
if (isHtml)
|
|
||||||
{
|
|
||||||
builder.Html = body;
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
builder.Text = body;
|
|
||||||
}
|
|
||||||
|
|
||||||
var mail = builder.Create();
|
|
||||||
|
|
||||||
var result = smtp.SendMessage(mail);
|
|
||||||
|
|
||||||
if (result.Status != SendMessageStatus.Success)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}");
|
|
||||||
}
|
|
||||||
|
|
||||||
smtp.Close();
|
|
||||||
await Task.CompletedTask; // For async consistency
|
|
||||||
}
|
|
||||||
catch (Limilabs.Client.ServerException ex)
|
|
||||||
{
|
|
||||||
DisconnectSafely(smtp);
|
|
||||||
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
DisconnectSafely(smtp);
|
|
||||||
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Private Helper Methods ---
|
|
||||||
|
|
||||||
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp)
|
|
||||||
{
|
|
||||||
if (_smtpAccount.SmtpUseSsl)
|
|
||||||
{
|
|
||||||
smtp.ConnectSSL(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
smtp.Connect(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_smtpAccount.UseOAuth2)
|
|
||||||
{
|
|
||||||
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false.");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
var password = _smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(_smtpAccount.Password) : _smtpAccount.Password;
|
|
||||||
|
|
||||||
smtp.Login(_smtpAccount.Username, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.CompletedTask; // For async consistency
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void DisconnectSafely(Smtp smtp)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
if (smtp.Connected)
|
|
||||||
smtp.Close();
|
|
||||||
}
|
|
||||||
catch { /* Ignore disconnect errors */ }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
|
||||||
|
/// Leave empty for Microsoft.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// </summary>
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
public string? OAuth2ClientSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only). Obtained via OAuth Playground or authorization flow.
|
||||||
|
/// Leave empty for Microsoft.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tenant ID for Microsoft OAuth2 (GUID, full domain, or "common"). Not used for Google.
|
||||||
|
/// </summary>
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// </summary>
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// </summary>
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record SendingEmailCreateDto
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Recipient email address
|
||||||
|
/// </summary>
|
||||||
|
public string Recipient { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email subject
|
||||||
|
/// </summary>
|
||||||
|
public string Subject { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Email body (HTML or plain text)
|
||||||
|
/// </summary>
|
||||||
|
public string Body { get; set; } = null!;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Is HTML email (default: true)
|
||||||
|
/// </summary>
|
||||||
|
public bool IsHtml { get; set; } = true;
|
||||||
|
|
||||||
|
public DateTime QueuedAt { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// When true, the sent message will be appended to the IMAP Sent folder after sending.
|
||||||
|
/// </summary>
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public bool UseImapAppend { get; set; } = false;
|
||||||
|
#else
|
||||||
|
public bool UseImapAppend { get; init; } = false;
|
||||||
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP folder to append the sent message to (used when <see cref="UseImapAppend"/> is true).
|
||||||
|
/// </summary>
|
||||||
|
#if NETFRAMEWORK
|
||||||
|
public string SentFolder { get; set; } = "Sent";
|
||||||
|
#else
|
||||||
|
public string SentFolder { get; init; } = "Sent";
|
||||||
|
#endif
|
||||||
|
}
|
||||||
@@ -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,6 @@
|
|||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
public interface IEmailSyncService
|
||||||
|
{
|
||||||
|
public DateTime ForceTriggerSync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
#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");
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sends an email via SMTP using the account credentials and appends the sent message
|
||||||
|
/// to the account's IMAP Sent Items folder.
|
||||||
|
/// </summary>
|
||||||
|
Task SendAndAppendAsync(
|
||||||
|
EmailContext context,
|
||||||
|
string sentFolder = "Sent",
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Manages the OAuth2 authorization code flow for providers that require
|
||||||
|
/// user-delegated access (e.g. Google). Generates authorization URLs and
|
||||||
|
/// exchanges authorization codes for refresh tokens.
|
||||||
|
/// </summary>
|
||||||
|
public interface IOAuth2AuthorizationService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Builds the authorization URL to redirect the user to for consent.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">The email account to authorize.</param>
|
||||||
|
/// <param name="redirectUri">The callback URI registered with the OAuth2 provider.</param>
|
||||||
|
/// <returns>The full authorization URL.</returns>
|
||||||
|
string GetAuthorizationUrl(EmailAccount account, string redirectUri);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges an authorization code for tokens and returns the refresh token.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="account">The email account being authorized.</param>
|
||||||
|
/// <param name="code">The authorization code received from the provider callback.</param>
|
||||||
|
/// <param name="redirectUri">The same redirect URI used in <see cref="GetAuthorizationUrl"/>.</param>
|
||||||
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>The refresh token to be stored on the account.</returns>
|
||||||
|
Task<string> ExchangeCodeForRefreshTokenAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
string code,
|
||||||
|
string redirectUri,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Acquires and caches OAuth2 access tokens for email protocols (IMAP, POP3, SMTP).
|
||||||
|
/// Uses the client credentials flow (application-level auth — no user interaction required).
|
||||||
|
/// </summary>
|
||||||
|
public interface IOAuth2TokenService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns a valid access token for the given email account.
|
||||||
|
/// Tokens are cached and refreshed automatically before expiry.
|
||||||
|
/// </summary>
|
||||||
|
Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
#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 POP3.
|
||||||
|
/// </summary>
|
||||||
|
public interface IPop3EmailService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Fetches new messages from the POP3 server and persists them locally.
|
||||||
|
/// Because POP3 has no folder concept, all messages are stored under the folder name "INBOX".
|
||||||
|
/// </summary>
|
||||||
|
Task<EmailSyncResult> SyncEmailsAsync(
|
||||||
|
EmailAccount account,
|
||||||
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last POP3 sync date for the specified account.
|
||||||
|
/// </summary>
|
||||||
|
DateTime? GetLastPop3SyncDate(int accountId);
|
||||||
|
}
|
||||||
|
#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);
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.EmailAccounts;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for Emails
|
||||||
|
/// </summary>
|
||||||
|
public class EmailMappingProfile : Profile
|
||||||
|
{
|
||||||
|
public EmailMappingProfile()
|
||||||
|
{
|
||||||
|
// PublishEmailCommand -> Email
|
||||||
|
// Sender is resolved via MediatR in the handler and set separately after mapping.
|
||||||
|
CreateMap<PublishEmailCommand, EmailContext>()
|
||||||
|
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||||
|
|
||||||
|
// PublishEmailViaImapCommand -> EmailContext
|
||||||
|
CreateMap<PublishEmailViaImapCommand, EmailContext>()
|
||||||
|
.ForMember(dest => dest.Sender, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Attachments, opt => opt.MapFrom(src => src.Attachments));
|
||||||
|
|
||||||
|
// PublishEmailViaOAuth2Command -> EmailContext
|
||||||
|
CreateMap<PublishEmailViaOAuth2Command, EmailContext>()
|
||||||
|
.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>()
|
||||||
|
// Do not overwrite OAuth2RefreshToken if the source value is null or empty.
|
||||||
|
// This prevents the seed process from erasing a token that was obtained via
|
||||||
|
// the OAuth2 authorization flow and saved to the database at runtime.
|
||||||
|
.ForMember(dest => dest.OAuth2RefreshToken,
|
||||||
|
opt => opt.Condition((src, dest, srcMember) => !string.IsNullOrEmpty(srcMember)));
|
||||||
|
|
||||||
|
// 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,19 @@
|
|||||||
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// AutoMapper profile for OAuth2 operations.
|
||||||
|
/// Registers EmailAccount self-mapping so UpdateSingleAsync can update an account
|
||||||
|
/// entity using another EmailAccount instance (e.g. after setting OAuth2RefreshToken).
|
||||||
|
/// </summary>
|
||||||
|
public class OAuth2MappingProfile : Profile
|
||||||
|
{
|
||||||
|
public OAuth2MappingProfile()
|
||||||
|
{
|
||||||
|
CreateMap<EmailAccount, EmailAccount>();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The minimum interval, in seconds, between forced IMAP sync operations regardless of idle state.
|
||||||
|
/// Defaults to 30 seconds (0.5 minute).
|
||||||
|
/// </summary>
|
||||||
|
public int ForcedSyncIntervalSeconds { get; init; } = 30;
|
||||||
|
}
|
||||||
|
#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" />
|
||||||
</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="MimeKit" Version="4.17.0" />
|
|
||||||
<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,58 @@
|
|||||||
|
#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<ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
/// <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, IImapEmailService imapEmailService) : IRequestHandler<ReadEmailQuery, ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
public async Task<ReadEmailQueryResponse> 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);
|
||||||
|
|
||||||
|
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
|
||||||
|
|
||||||
|
return new ReadEmailQueryResponse
|
||||||
|
{
|
||||||
|
LastSync = lastSync,
|
||||||
|
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
///
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="Emails"></param>
|
||||||
|
/// <param name="LastSync"></param>
|
||||||
|
public class ReadEmailQueryResponse
|
||||||
|
{
|
||||||
|
public DateTime? LastSync { get; init; } = null;
|
||||||
|
|
||||||
|
public IEnumerable<ReceivedEmailDto> Emails { get; init; } = [];
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
#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.Application.EmailReceiving.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 using OAuth2 authentication.
|
||||||
|
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||||
|
/// </summary>
|
||||||
|
public record ReadEmailViaOAuth2Query : IRequest<ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
/// <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 ReadEmailViaOAuth2QueryHandler(
|
||||||
|
IMapper Mapper,
|
||||||
|
ILogger<ReadEmailViaOAuth2QueryHandler> Logger,
|
||||||
|
IRepository<EmailAccount> EmailAccountRepo,
|
||||||
|
IReceivedEmailRepository MailRepo,
|
||||||
|
IImapEmailService imapEmailService) : IRequestHandler<ReadEmailViaOAuth2Query, ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaOAuth2Query 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 ({Criteria}). Using first.",
|
||||||
|
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 (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (!account.UseOAuth2)
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"Account '{account.Username}' (Id: {account.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId) ||
|
||||||
|
string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{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);
|
||||||
|
|
||||||
|
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
|
||||||
|
|
||||||
|
return new ReadEmailQueryResponse
|
||||||
|
{
|
||||||
|
LastSync = lastSync,
|
||||||
|
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
#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.Application.EmailReceiving.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 a POP3 mailbox.
|
||||||
|
/// Triggers an on-demand sync and returns stored results filtered by <see cref="Mail"/>.
|
||||||
|
/// </summary>
|
||||||
|
public record ReadEmailViaPop3Query : IRequest<ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
/// <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 from local storage.
|
||||||
|
/// Note: POP3 has no folder concept — all messages are stored under "INBOX".
|
||||||
|
/// </summary>
|
||||||
|
public MailSearchFilter Mail { get; init; } = new();
|
||||||
|
}
|
||||||
|
|
||||||
|
public class ReadEmailViaPop3QueryHandler(
|
||||||
|
IMapper Mapper,
|
||||||
|
ILogger<ReadEmailViaPop3QueryHandler> Logger,
|
||||||
|
IRepository<EmailAccount> EmailAccountRepo,
|
||||||
|
IReceivedEmailRepository MailRepo,
|
||||||
|
IPop3EmailService pop3EmailService) : IRequestHandler<ReadEmailViaPop3Query, ReadEmailQueryResponse>
|
||||||
|
{
|
||||||
|
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaPop3Query 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 ({Criteria}). Using first.",
|
||||||
|
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 (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(account.Pop3Server))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"POP3 is not configured for account '{account.Username}' (Id: {account.Id}). Set Pop3Server in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
// Trigger on-demand POP3 sync before querying local storage
|
||||||
|
await pop3EmailService.SyncEmailsAsync(account, cancellationToken);
|
||||||
|
|
||||||
|
// POP3 has no folder concept — always query INBOX
|
||||||
|
var filter = request.Mail with { Folder = "INBOX" };
|
||||||
|
var mails = await MailRepo.FindAsync(filter, account, cancellationToken);
|
||||||
|
|
||||||
|
var lastSync = pop3EmailService.GetLastPop3SyncDate(account.Id);
|
||||||
|
|
||||||
|
return new ReadEmailQueryResponse
|
||||||
|
{
|
||||||
|
LastSync = lastSync,
|
||||||
|
Emails = 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
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
#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 using IMAP account credentials (queued via RabbitMQ).
|
||||||
|
/// After processing, the sent message is appended to the IMAP Sent Items folder.
|
||||||
|
/// </summary>
|
||||||
|
public record PublishEmailViaImapCommand : IRequest<Guid>
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Sender { get; init; }
|
||||||
|
|
||||||
|
public required IEnumerable<string> Recipients { get; init; }
|
||||||
|
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
public required string Body { get; init; }
|
||||||
|
|
||||||
|
public bool IsHtml { get; init; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// IMAP folder to which the sent message will be appended (default: "Sent").
|
||||||
|
/// </summary>
|
||||||
|
public string SentFolder { get; init; } = "Sent";
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||||
|
|
||||||
|
public PublishEmailViaImapCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||||
|
=> this with { Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublishEmailViaImapCommandHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
ISendingEmailPublisher Publisher,
|
||||||
|
IMapper Mapper,
|
||||||
|
ILogger<PublishEmailViaImapCommandHandler> Logger) : IRequestHandler<PublishEmailViaImapCommand, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(PublishEmailViaImapCommand 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 ({Criteria}). Using first.",
|
||||||
|
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 (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(senderAccount.ImapServer))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"IMAP is not configured for account '{senderAccount.Username}' (Id: {senderAccount.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
|
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||||
|
|
||||||
|
var sendingEmailEvent = new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = emailContext,
|
||||||
|
QueuedAt = DateTime.Now,
|
||||||
|
SentFolder = request.SentFolder,
|
||||||
|
UseImapAppend = true
|
||||||
|
};
|
||||||
|
|
||||||
|
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||||
|
return sendingEmailEvent.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
#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 via SMTP using OAuth2 authentication (queued via RabbitMQ).
|
||||||
|
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||||
|
/// </summary>
|
||||||
|
public record PublishEmailViaOAuth2Command : IRequest<Guid>
|
||||||
|
{
|
||||||
|
public required GetEmailAccountQuery Sender { get; init; }
|
||||||
|
|
||||||
|
public required IEnumerable<string> Recipients { get; init; }
|
||||||
|
|
||||||
|
public required string Subject { get; init; }
|
||||||
|
|
||||||
|
public required string Body { get; init; }
|
||||||
|
|
||||||
|
public bool IsHtml { get; init; } = true;
|
||||||
|
|
||||||
|
[JsonIgnore]
|
||||||
|
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
|
||||||
|
|
||||||
|
public PublishEmailViaOAuth2Command WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
|
||||||
|
=> this with { Attachments = attachments };
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PublishEmailViaOAuth2CommandHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
ISendingEmailPublisher Publisher,
|
||||||
|
IMapper Mapper,
|
||||||
|
ILogger<PublishEmailViaOAuth2CommandHandler> Logger) : IRequestHandler<PublishEmailViaOAuth2Command, Guid>
|
||||||
|
{
|
||||||
|
public async Task<Guid> Handle(PublishEmailViaOAuth2Command 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 ({Criteria}). Using first.",
|
||||||
|
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 (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
|
||||||
|
|
||||||
|
if (!senderAccount.UseOAuth2)
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"Account '{senderAccount.Username}' (Id: {senderAccount.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientId) ||
|
||||||
|
string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientSecret))
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{senderAccount.Username}'.");
|
||||||
|
|
||||||
|
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
|
||||||
|
|
||||||
|
var sendingEmailEvent = new SendingEmailEvent
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
Mail = emailContext,
|
||||||
|
QueuedAt = DateTime.Now
|
||||||
|
};
|
||||||
|
|
||||||
|
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
|
||||||
|
return sendingEmailEvent.Id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||||
|
using FluentValidation;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.EmailSending.Validators;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Validator for PublishEmailCommand
|
||||||
|
/// </summary>
|
||||||
|
public class PublishEmailCommandValidator : AbstractValidator<PublishEmailCommand>
|
||||||
|
{
|
||||||
|
public PublishEmailCommandValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.Recipients)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Recipients are required")
|
||||||
|
.Must(x => x.Any())
|
||||||
|
.WithMessage("At least one recipient is required")
|
||||||
|
.ForEach(recipient => recipient
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Recipient email must not be empty")
|
||||||
|
.EmailAddress()
|
||||||
|
.WithMessage("Invalid email address format"));
|
||||||
|
|
||||||
|
RuleFor(x => x.Subject)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Subject is required")
|
||||||
|
.MaximumLength(500)
|
||||||
|
.WithMessage("Subject must not exceed 500 characters");
|
||||||
|
|
||||||
|
RuleFor(x => x.Body)
|
||||||
|
.NotEmpty()
|
||||||
|
.WithMessage("Body is required");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.OAuth2.Commands;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Exchanges a Google OAuth2 authorization code for a refresh token
|
||||||
|
/// and persists it on the email account.
|
||||||
|
/// Call this from the OAuth2 callback endpoint after the user grants consent.
|
||||||
|
/// </summary>
|
||||||
|
public record CompleteOAuth2AuthorizationCommand : IRequest<CompleteOAuth2AuthorizationResult>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account being authorized.
|
||||||
|
/// </summary>
|
||||||
|
public required int AccountId { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The authorization code received from the OAuth2 provider callback.
|
||||||
|
/// </summary>
|
||||||
|
public required string Code { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The redirect URI used in the original authorization request.
|
||||||
|
/// </summary>
|
||||||
|
public required string RedirectUri { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CompleteOAuth2AuthorizationResult
|
||||||
|
{
|
||||||
|
public required string Username { get; init; }
|
||||||
|
public required bool Success { get; init; }
|
||||||
|
public string? ErrorMessage { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class CompleteOAuth2AuthorizationCommandHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
IOAuth2AuthorizationService AuthService,
|
||||||
|
ILogger<CompleteOAuth2AuthorizationCommandHandler> Logger) : IRequestHandler<CompleteOAuth2AuthorizationCommand, CompleteOAuth2AuthorizationResult>
|
||||||
|
{
|
||||||
|
public async Task<CompleteOAuth2AuthorizationResult> Handle(CompleteOAuth2AuthorizationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await Repo.GetByIdAsync(request.AccountId, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"No email account found with Id: {request.AccountId}.");
|
||||||
|
|
||||||
|
Logger.LogInformation("Exchanging OAuth2 authorization code for account '{Username}' (Id: {Id}).",
|
||||||
|
account.Username, account.Id);
|
||||||
|
|
||||||
|
var refreshToken = await AuthService.ExchangeCodeForRefreshTokenAsync(
|
||||||
|
account, request.Code, request.RedirectUri, cancellationToken);
|
||||||
|
|
||||||
|
// Set the refresh token directly on the tracked entity — no AutoMapper needed
|
||||||
|
account.OAuth2RefreshToken = refreshToken;
|
||||||
|
await Repo.UpdateSingleAsync(a => a.Id == account.Id, account, cancellationToken);
|
||||||
|
|
||||||
|
Logger.LogInformation("OAuth2 refresh token saved for account '{Username}' (Id: {Id}).",
|
||||||
|
account.Username, account.Id);
|
||||||
|
|
||||||
|
return new CompleteOAuth2AuthorizationResult
|
||||||
|
{
|
||||||
|
Username = account.Username,
|
||||||
|
Success = true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
using DigitalData.MessagingService.Domain.Exceptions;
|
||||||
|
using MediatR;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.OAuth2.Queries;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the authorization URL the user must visit to grant OAuth2 access.
|
||||||
|
/// Supported for providers that require user-delegated access (e.g. Google).
|
||||||
|
/// </summary>
|
||||||
|
public record GetOAuth2AuthorizationUrlQuery : IRequest<string>
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account to authorize.
|
||||||
|
/// </summary>
|
||||||
|
public required string Username { get; init; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The redirect URI registered with the OAuth2 provider.
|
||||||
|
/// Must exactly match the URI configured in the provider's developer console.
|
||||||
|
/// </summary>
|
||||||
|
public required string RedirectUri { get; init; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public class GetOAuth2AuthorizationUrlQueryHandler(
|
||||||
|
IRepository<EmailAccount> Repo,
|
||||||
|
IOAuth2AuthorizationService AuthService) : IRequestHandler<GetOAuth2AuthorizationUrlQuery, string>
|
||||||
|
{
|
||||||
|
public async Task<string> Handle(GetOAuth2AuthorizationUrlQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var account = await Repo.FindFirstAsync(e => e.Username == request.Username, cancellationToken)
|
||||||
|
?? throw new NotFoundException($"No email account found with username: {request.Username}.");
|
||||||
|
|
||||||
|
if (account.OAuth2Provider == OAuth2Provider.None)
|
||||||
|
throw new BadRequestException(
|
||||||
|
$"Account '{account.Username}' (Id: {account.Id}) has no OAuth2 provider configured. " +
|
||||||
|
$"Set OAuth2Provider to a supported value (e.g. Google).");
|
||||||
|
|
||||||
|
return AuthService.GetAuthorizationUrl(account, request.RedirectUri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -1,17 +1,18 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<TargetFramework>net8.0</TargetFramework>
|
<TargetFrameworks>net462;net8.0</TargetFrameworks>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
|
||||||
<Folder Include="Events\" />
|
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POP3 server hostname (e.g. "pop.example.com").
|
||||||
|
/// Leave empty when this account does not use POP3.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("POP3_SERVER", TypeName = "nvarchar(256)")]
|
||||||
|
public string? Pop3Server { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// POP3 server port (995 for SSL, 110 for plain).
|
||||||
|
/// </summary>
|
||||||
|
[Column("POP3_PORT", TypeName = "int")]
|
||||||
|
public int Pop3Port { get; set; } = 995;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Use SSL/TLS when connecting to the POP3 server.
|
||||||
|
/// </summary>
|
||||||
|
[Column("POP3_USE_SSL", TypeName = "bit")]
|
||||||
|
public bool Pop3UseSsl { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 client ID (required when <see cref="UseOAuth2"/> is true).
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("OAUTH2_CLIENT_ID", TypeName = "nvarchar(512)")]
|
||||||
|
public string? OAuth2ClientId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 client secret (required when <see cref="UseOAuth2"/> is true).
|
||||||
|
/// For Microsoft: the app registration client secret value from Azure Portal.
|
||||||
|
/// For Google: the client secret from Google Cloud Console credentials JSON.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(512)]
|
||||||
|
[Column("OAUTH2_CLIENT_SECRET", TypeName = "nvarchar(512)")]
|
||||||
|
public string? OAuth2ClientSecret { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 refresh token (Google only).
|
||||||
|
/// Obtained once via the OAuth2 authorization flow (e.g. OAuth Playground).
|
||||||
|
/// Used to exchange for short-lived access tokens without user interaction.
|
||||||
|
/// Leave empty for Microsoft — MSAL handles token refresh internally.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(1024)]
|
||||||
|
[Column("OAUTH2_REFRESH_TOKEN", TypeName = "nvarchar(1024)")]
|
||||||
|
public string? OAuth2RefreshToken { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// OAuth2 tenant ID (e.g. for Microsoft 365: tenant GUID or "common").
|
||||||
|
/// Not required for Google — leave empty.
|
||||||
|
/// </summary>
|
||||||
|
[MaxLength(256)]
|
||||||
|
[Column("OAUTH2_TENANT_ID", TypeName = "nvarchar(256)")]
|
||||||
|
public string? OAuth2TenantId { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies which OAuth2 identity provider to use when <see cref="UseOAuth2"/> is true.
|
||||||
|
/// Determines which token acquisition strategy is applied.
|
||||||
|
/// </summary>
|
||||||
|
[Column("OAUTH2_PROVIDER", TypeName = "int")]
|
||||||
|
public OAuth2Provider OAuth2Provider { get; set; } = OAuth2Provider.None;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The protocol used to receive (sync) incoming emails.
|
||||||
|
/// When set to <see cref="IncomingProtocol.None"/>, this account is send-only and will be skipped by the sync worker.
|
||||||
|
/// Takes priority over the presence of <see cref="ImapServer"/> or <see cref="Pop3Server"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Column("INCOMING_PROTOCOL", TypeName = "int")]
|
||||||
|
public IncomingProtocol IncomingProtocol { get; set; } = IncomingProtocol.None;
|
||||||
|
}
|
||||||
@@ -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,34 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Specifies the protocol used to receive (sync) incoming emails for an account.
|
||||||
|
/// </summary>
|
||||||
|
public enum IncomingProtocol
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No incoming protocol configured — account is send-only.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync emails via IMAP using username/password authentication.
|
||||||
|
/// </summary>
|
||||||
|
Imap = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync emails via POP3 using username/password authentication.
|
||||||
|
/// </summary>
|
||||||
|
Pop3 = 2,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync emails via IMAP using OAuth2 (XOAUTH2) authentication.
|
||||||
|
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
|
||||||
|
/// </summary>
|
||||||
|
ImapOAuth2 = 3,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sync emails via POP3 using OAuth2 (XOAUTH2) authentication.
|
||||||
|
/// Requires <c>UseOAuth2 = true</c> and valid OAuth2 credentials.
|
||||||
|
/// </summary>
|
||||||
|
Pop3OAuth2 = 4,
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Enums;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies the OAuth2 identity provider used to acquire access tokens.
|
||||||
|
/// Only relevant when <c>UseOAuth2 = true</c>.
|
||||||
|
/// </summary>
|
||||||
|
public enum OAuth2Provider
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// No OAuth2 provider — account uses plain username/password authentication.
|
||||||
|
/// </summary>
|
||||||
|
None = 0,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Microsoft identity platform (Azure AD / Microsoft 365 / Exchange Online).
|
||||||
|
/// Uses MSAL with the client credentials flow against
|
||||||
|
/// <c>https://login.microsoftonline.com/{tenant}</c>.
|
||||||
|
/// Requires <c>OAuth2ClientId</c>, <c>OAuth2ClientSecret</c> and <c>OAuth2TenantId</c>.
|
||||||
|
/// </summary>
|
||||||
|
Microsoft = 1,
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Google identity platform (Gmail / Google Workspace).
|
||||||
|
/// Uses the service-account or OAuth2 client credentials flow against
|
||||||
|
/// <c>https://oauth2.googleapis.com/token</c>.
|
||||||
|
/// Requires <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c>.
|
||||||
|
/// </summary>
|
||||||
|
Google = 2,
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Exceptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when OAuth2 authentication fails.
|
||||||
|
/// </summary>
|
||||||
|
public class AuthenticationFailedException : Exception
|
||||||
|
{
|
||||||
|
public AuthenticationFailedException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public AuthenticationFailedException(string message, Exception innerException) : base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
namespace DigitalData.MessagingService.Domain.Exceptions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Exception thrown when a requested entity is not found.
|
||||||
|
/// </summary>
|
||||||
|
public class NotFoundException : Exception
|
||||||
|
{
|
||||||
|
public NotFoundException(string entityName, object key) : base($"{entityName} with key '{key}' was not found.")
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotFoundException(string message) : base(message)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
public NotFoundException(string message, Exception innerException)
|
||||||
|
: base(message, innerException)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user