Compare commits
212 Commits
b87472db3e
...
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 | |||
| 5e587da957 | |||
| 77d3b52d16 | |||
| 3cba69ec42 | |||
| d91ed70001 | |||
| 4a6af885de | |||
| 55e5d689ad | |||
| 42e9361f1d | |||
| fbd6c0c521 | |||
| 55feaed361 | |||
| 169ef7d86b | |||
| 0e53e8f726 | |||
| 615bf555f8 | |||
| 851a4e94f9 | |||
| 3e04fd7b63 | |||
| 6a5e0a6086 | |||
| 0ee6e4f96e | |||
| 4e164a9162 | |||
| 3d13d10615 | |||
| f3eb4bb69b | |||
| 27513e73f5 | |||
| 658040bd96 | |||
| 0adc74e19f | |||
| 3be6e28477 | |||
| 71e29ac3bb | |||
| 828bb168eb | |||
| 70dd210555 | |||
| 860ce41192 | |||
| 1404f90729 | |||
| 958352a720 | |||
| 8003715792 | |||
| d346ed3176 | |||
| 3f9bfc78a8 | |||
| dbd0d35ba3 | |||
| 751ef87506 | |||
| 8f2365d048 | |||
| bfe24eba06 | |||
| 0d22fe0b5c | |||
| 1ed489532d | |||
| eda6257145 | |||
| a708799587 | |||
| b7d65d7d5c | |||
| 5e8e6a06fe | |||
| 50c21ee628 | |||
| 45654796b7 | |||
| 3778c0b338 | |||
| 111d2bf264 | |||
| c9251fa622 | |||
| 2393b2649a | |||
| 331b73000e | |||
| e789afe26a | |||
| dd04cd6cba | |||
| 43101a6e61 | |||
| 146b56ff85 | |||
| 721603bb47 | |||
| c97073775b | |||
| 18bb07cd93 | |||
| f6946d812a | |||
| 6098112bb4 | |||
| 05a36e8045 | |||
| 915d01fc03 | |||
| a88702d9e2 | |||
| 1144f58ebb | |||
| 690aee02dd | |||
| f3552dbdaa | |||
| 8a19a8a8bb | |||
| 34baa6fbd9 |
63
.gitattributes
vendored
Normal file
63
.gitattributes
vendored
Normal file
@@ -0,0 +1,63 @@
|
||||
###############################################################################
|
||||
# Set default behavior to automatically normalize line endings.
|
||||
###############################################################################
|
||||
* text=auto
|
||||
|
||||
###############################################################################
|
||||
# Set default behavior for command prompt diff.
|
||||
#
|
||||
# This is need for earlier builds of msysgit that does not have it on by
|
||||
# default for csharp files.
|
||||
# Note: This is only used by command line
|
||||
###############################################################################
|
||||
#*.cs diff=csharp
|
||||
|
||||
###############################################################################
|
||||
# Set the merge driver for project and solution files
|
||||
#
|
||||
# Merging from the command prompt will add diff markers to the files if there
|
||||
# are conflicts (Merging from VS is not affected by the settings below, in VS
|
||||
# the diff markers are never inserted). Diff markers may cause the following
|
||||
# file extensions to fail to load in VS. An alternative would be to treat
|
||||
# these files as binary and thus will always conflict and require user
|
||||
# intervention with every merge. To do so, just uncomment the entries below
|
||||
###############################################################################
|
||||
#*.sln merge=binary
|
||||
#*.csproj merge=binary
|
||||
#*.vbproj merge=binary
|
||||
#*.vcxproj merge=binary
|
||||
#*.vcproj merge=binary
|
||||
#*.dbproj merge=binary
|
||||
#*.fsproj merge=binary
|
||||
#*.lsproj merge=binary
|
||||
#*.wixproj merge=binary
|
||||
#*.modelproj merge=binary
|
||||
#*.sqlproj merge=binary
|
||||
#*.wwaproj merge=binary
|
||||
|
||||
###############################################################################
|
||||
# behavior for image files
|
||||
#
|
||||
# image files are treated as binary by default.
|
||||
###############################################################################
|
||||
#*.jpg binary
|
||||
#*.png binary
|
||||
#*.gif binary
|
||||
|
||||
###############################################################################
|
||||
# diff behavior for common document formats
|
||||
#
|
||||
# Convert binary document formats to text before diffing them. This feature
|
||||
# is only available from the command line. Turn it on by uncommenting the
|
||||
# entries below.
|
||||
###############################################################################
|
||||
#*.doc diff=astextplain
|
||||
#*.DOC diff=astextplain
|
||||
#*.docx diff=astextplain
|
||||
#*.DOCX diff=astextplain
|
||||
#*.dot diff=astextplain
|
||||
#*.DOT diff=astextplain
|
||||
#*.pdf diff=astextplain
|
||||
#*.PDF diff=astextplain
|
||||
#*.rtf diff=astextplain
|
||||
#*.RTF diff=astextplain
|
||||
376
.gitignore
vendored
Normal file
376
.gitignore
vendored
Normal file
@@ -0,0 +1,376 @@
|
||||
## Ignore Visual Studio temporary files, build results, and
|
||||
## files generated by popular Visual Studio add-ons.
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
*.user
|
||||
*.userosscache
|
||||
*.sln.docstates
|
||||
|
||||
# User-specific files (MonoDevelop/Xamarin Studio)
|
||||
*.userprefs
|
||||
|
||||
# Mono auto generated files
|
||||
mono_crash.*
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Dd]ebugPublic/
|
||||
[Rr]elease/
|
||||
[Rr]eleases/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
bld/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
[Oo]ut/
|
||||
[Ll]og/
|
||||
[Ll]ogs/
|
||||
|
||||
# Visual Studio 2015/2017 cache/options directory
|
||||
.vs/
|
||||
# Uncomment if you have tasks that create the project's static files in wwwroot
|
||||
#wwwroot/
|
||||
|
||||
# Visual Studio 2017 auto generated files
|
||||
Generated\ Files/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NUnit
|
||||
*.VisualState.xml
|
||||
TestResult.xml
|
||||
nunit-*.xml
|
||||
|
||||
# Build Results of an ATL Project
|
||||
[Dd]ebugPS/
|
||||
[Rr]eleasePS/
|
||||
dlldata.c
|
||||
|
||||
# Benchmark Results
|
||||
BenchmarkDotNet.Artifacts/
|
||||
|
||||
# .NET Core
|
||||
project.lock.json
|
||||
project.fragment.lock.json
|
||||
artifacts/
|
||||
|
||||
# ASP.NET Scaffolding
|
||||
ScaffoldingReadMe.txt
|
||||
|
||||
# StyleCop
|
||||
StyleCopReport.xml
|
||||
|
||||
# Files built by Visual Studio
|
||||
*_i.c
|
||||
*_p.c
|
||||
*_h.h
|
||||
*.ilk
|
||||
*.meta
|
||||
*.obj
|
||||
*.iobj
|
||||
*.pch
|
||||
*.pdb
|
||||
*.ipdb
|
||||
*.pgc
|
||||
*.pgd
|
||||
*.rsp
|
||||
*.sbr
|
||||
*.tlb
|
||||
*.tli
|
||||
*.tlh
|
||||
*.tmp
|
||||
*.tmp_proj
|
||||
*_wpftmp.csproj
|
||||
*.log
|
||||
*.vspscc
|
||||
*.vssscc
|
||||
.builds
|
||||
*.pidb
|
||||
*.svclog
|
||||
*.scc
|
||||
|
||||
# Chutzpah Test files
|
||||
_Chutzpah*
|
||||
|
||||
# Visual C++ cache files
|
||||
ipch/
|
||||
*.aps
|
||||
*.ncb
|
||||
*.opendb
|
||||
*.opensdf
|
||||
*.sdf
|
||||
*.cachefile
|
||||
*.VC.db
|
||||
*.VC.VC.opendb
|
||||
|
||||
# Visual Studio profiler
|
||||
*.psess
|
||||
*.vsp
|
||||
*.vspx
|
||||
*.sap
|
||||
|
||||
# Visual Studio Trace Files
|
||||
*.e2e
|
||||
|
||||
# TFS 2012 Local Workspace
|
||||
$tf/
|
||||
|
||||
# Guidance Automation Toolkit
|
||||
*.gpState
|
||||
|
||||
# ReSharper is a .NET coding add-in
|
||||
_ReSharper*/
|
||||
*.[Rr]e[Ss]harper
|
||||
*.DotSettings.user
|
||||
|
||||
# TeamCity is a build add-in
|
||||
_TeamCity*
|
||||
|
||||
# DotCover is a Code Coverage Tool
|
||||
*.dotCover
|
||||
|
||||
# AxoCover is a Code Coverage Tool
|
||||
.axoCover/*
|
||||
!.axoCover/settings.json
|
||||
|
||||
# Coverlet is a free, cross platform Code Coverage Tool
|
||||
coverage*.json
|
||||
coverage*.xml
|
||||
coverage*.info
|
||||
|
||||
# Visual Studio code coverage results
|
||||
*.coverage
|
||||
*.coveragexml
|
||||
|
||||
# NCrunch
|
||||
_NCrunch_*
|
||||
.*crunch*.local.xml
|
||||
nCrunchTemp_*
|
||||
|
||||
# MightyMoose
|
||||
*.mm.*
|
||||
AutoTest.Net/
|
||||
|
||||
# Web workbench (sass)
|
||||
.sass-cache/
|
||||
|
||||
# Installshield output folder
|
||||
[Ee]xpress/
|
||||
|
||||
# DocProject is a documentation generator add-in
|
||||
DocProject/buildhelp/
|
||||
DocProject/Help/*.HxT
|
||||
DocProject/Help/*.HxC
|
||||
DocProject/Help/*.hhc
|
||||
DocProject/Help/*.hhk
|
||||
DocProject/Help/*.hhp
|
||||
DocProject/Help/Html2
|
||||
DocProject/Help/html
|
||||
|
||||
# Click-Once directory
|
||||
publish/
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
*.azurePubxml
|
||||
# Note: Comment the next line if you want to checkin your web deploy settings,
|
||||
# but database connection strings (with potential passwords) will be unencrypted
|
||||
*.pubxml
|
||||
*.publishproj
|
||||
|
||||
# Microsoft Azure Web App publish settings. Comment the next line if you want to
|
||||
# checkin your Azure Web App publish settings, but sensitive information contained
|
||||
# in these scripts will be unencrypted
|
||||
PublishScripts/
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
# NuGet Symbol Packages
|
||||
*.snupkg
|
||||
# The packages folder can be ignored because of Package Restore
|
||||
**/[Pp]ackages/*
|
||||
# except build/, which is used as an MSBuild target.
|
||||
!**/[Pp]ackages/build/
|
||||
# Uncomment if necessary however generally it will be regenerated when needed
|
||||
#!**/[Pp]ackages/repositories.config
|
||||
# NuGet v3's project.json files produces more ignorable files
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Microsoft Azure Build Output
|
||||
csx/
|
||||
*.build.csdef
|
||||
|
||||
# Microsoft Azure Emulator
|
||||
ecf/
|
||||
rcf/
|
||||
|
||||
# Windows Store app package directories and files
|
||||
AppPackages/
|
||||
BundleArtifacts/
|
||||
Package.StoreAssociation.xml
|
||||
_pkginfo.txt
|
||||
*.appx
|
||||
*.appxbundle
|
||||
*.appxupload
|
||||
|
||||
# Visual Studio cache files
|
||||
# files ending in .cache can be ignored
|
||||
*.[Cc]ache
|
||||
# but keep track of directories ending in .cache
|
||||
!?*.[Cc]ache/
|
||||
|
||||
# Others
|
||||
ClientBin/
|
||||
~$*
|
||||
*~
|
||||
*.dbmdl
|
||||
*.dbproj.schemaview
|
||||
*.jfm
|
||||
*.pfx
|
||||
*.publishsettings
|
||||
orleans.codegen.cs
|
||||
|
||||
# Including strong name files can present a security risk
|
||||
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
|
||||
#*.snk
|
||||
|
||||
# Since there are multiple workflows, uncomment next line to ignore bower_components
|
||||
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
|
||||
#bower_components/
|
||||
|
||||
# RIA/Silverlight projects
|
||||
Generated_Code/
|
||||
|
||||
# Backup & report files from converting an old project file
|
||||
# to a newer Visual Studio version. Backup files are not needed,
|
||||
# because we have git ;-)
|
||||
_UpgradeReport_Files/
|
||||
Backup*/
|
||||
UpgradeLog*.XML
|
||||
UpgradeLog*.htm
|
||||
ServiceFabricBackup/
|
||||
*.rptproj.bak
|
||||
|
||||
# SQL Server files
|
||||
*.mdf
|
||||
*.ldf
|
||||
*.ndf
|
||||
|
||||
# Business Intelligence projects
|
||||
*.rdl.data
|
||||
*.bim.layout
|
||||
*.bim_*.settings
|
||||
*.rptproj.rsuser
|
||||
*- [Bb]ackup.rdl
|
||||
*- [Bb]ackup ([0-9]).rdl
|
||||
*- [Bb]ackup ([0-9][0-9]).rdl
|
||||
|
||||
# Microsoft Fakes
|
||||
FakesAssemblies/
|
||||
|
||||
# GhostDoc plugin setting file
|
||||
*.GhostDoc.xml
|
||||
|
||||
# Node.js Tools for Visual Studio
|
||||
.ntvs_analysis.dat
|
||||
node_modules/
|
||||
|
||||
# Visual Studio 6 build log
|
||||
*.plg
|
||||
|
||||
# Visual Studio 6 workspace options file
|
||||
*.opt
|
||||
|
||||
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
|
||||
*.vbw
|
||||
|
||||
# Visual Studio LightSwitch build output
|
||||
**/*.HTMLClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/GeneratedArtifacts
|
||||
**/*.DesktopClient/ModelManifest.xml
|
||||
**/*.Server/GeneratedArtifacts
|
||||
**/*.Server/ModelManifest.xml
|
||||
_Pvt_Extensions
|
||||
|
||||
# Paket dependency manager
|
||||
.paket/paket.exe
|
||||
paket-files/
|
||||
|
||||
# FAKE - F# Make
|
||||
.fake/
|
||||
|
||||
# CodeRush personal settings
|
||||
.cr/personal
|
||||
|
||||
# Python Tools for Visual Studio (PTVS)
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
# Cake - Uncomment if you are using it
|
||||
# tools/**
|
||||
# !tools/packages.config
|
||||
|
||||
# Tabs Studio
|
||||
*.tss
|
||||
|
||||
# Telerik's JustMock configuration file
|
||||
*.jmconfig
|
||||
|
||||
# BizTalk build output
|
||||
*.btp.cs
|
||||
*.btm.cs
|
||||
*.odx.cs
|
||||
*.xsd.cs
|
||||
|
||||
# OpenCover UI analysis results
|
||||
OpenCover/
|
||||
|
||||
# Azure Stream Analytics local run output
|
||||
ASALocalRun/
|
||||
|
||||
# MSBuild Binary and Structured Log
|
||||
*.binlog
|
||||
|
||||
# NVidia Nsight GPU debugger configuration file
|
||||
*.nvuser
|
||||
|
||||
# MFractors (Xamarin productivity tool) working folder
|
||||
.mfractor/
|
||||
|
||||
# Local History for Visual Studio
|
||||
.localhistory/
|
||||
|
||||
# BeatPulse healthcheck temp database
|
||||
healthchecksdb
|
||||
|
||||
# Backup folder for Package Reference Convert tool in Visual Studio 2017
|
||||
MigrationBackup/
|
||||
|
||||
# Ionide (cross platform F# VS Code tools) working folder
|
||||
.ionide/
|
||||
|
||||
# Fody - auto-generated XML schema
|
||||
FodyWeavers.xsd
|
||||
/EnvelopeGenerator.Web/.config/dotnet-tools.json
|
||||
/EnvelopeGenerator.GeneratorAPI/ClientApp/envelope-generator-ui/.vscode
|
||||
/EnvelopeGenerator.Tests.Application/Services/BugFixTests.cs
|
||||
/EnvelopeGenerator.Tests.Application/annotations.json
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/TekH - SoftHSM Test.md
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/publish-output
|
||||
/EnvelopeGenerator.Server/EnvelopeGenerator.Server/tekh_softHSM_test.md
|
||||
/legacy/App
|
||||
/src/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/appsettings.Secrets.json
|
||||
/src/presentation/DigitalData.MessagingService.API/oauth.htek0100@gmail.com.json
|
||||
714
AGENTS.md
Normal file
714
AGENTS.md
Normal file
@@ -0,0 +1,714 @@
|
||||
# MessagingService - Agent Notes and Future Enhancements
|
||||
|
||||
## Purpose
|
||||
This document contains important notes, decisions, and future enhancement plans for the MessagingService application. This is intended for AI agents and developers who will continue development.
|
||||
|
||||
---
|
||||
|
||||
## Important Notes
|
||||
|
||||
### 1. Database Schema - DO NOT MODIFY
|
||||
**CRITICAL**: The database schema must NEVER be modified. All Entity Framework entities must map to existing legacy tables using `[Table]` and `[Column]` attributes.
|
||||
|
||||
**Naming Convention**:
|
||||
- Database: `SNAKE_CASE` with prefixes (TBEMLP_, TBDD_)
|
||||
- C# Entities: `PascalCase` without prefixes
|
||||
- Use `[Table("TBDD_FOO")]` and `[Column("COLUMN_NAME")]` attributes
|
||||
|
||||
**Example**:
|
||||
```csharp
|
||||
[Table("TBDD_EMAIL_ACCOUNT")]
|
||||
public class EmailAccount
|
||||
{
|
||||
[Column("EMAIL_ACCOUNT_ID")]
|
||||
public int Id { get; set; }
|
||||
|
||||
[Column("ACCOUNT_NAME")]
|
||||
public string AccountName { get; set; }
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Message ID Hash Algorithm
|
||||
The `MessageIdGenerator` in `Domain.Services` must use **exactly the same algorithm** as the legacy system to ensure duplicate detection works correctly.
|
||||
|
||||
**Algorithm**: SHA256 hash of `{originalMessageId}|{sender}|{date:yyyyMMddHHmmss}|{subject}`
|
||||
|
||||
### 3. DateTime Usage - ALWAYS Use Local Time
|
||||
**CRITICAL**: Always use `DateTime.Now` instead of `DateTime.UtcNow` throughout the entire application.
|
||||
|
||||
**Reason**: The legacy system uses local server time, and the database stores all timestamps as local time. Using UTC would break compatibility and cause incorrect time comparisons.
|
||||
|
||||
**Examples**:
|
||||
```csharp
|
||||
// ✅ CORRECT
|
||||
profile.CreatedDate = DateTime.Now;
|
||||
var lastPoll = DateTime.Now.AddMinutes(-profile.PollIntervalMinutes);
|
||||
|
||||
// ❌ WRONG - DO NOT USE
|
||||
profile.CreatedDate = DateTime.UtcNow; // NEVER USE UTC
|
||||
var lastPoll = DateTime.UtcNow.AddMinutes(-profile.PollIntervalMinutes); // NEVER USE UTC
|
||||
```
|
||||
|
||||
**Important**: This applies to:
|
||||
- All entity audit fields (CreatedDate, ModifiedDate, LastPollDate, etc.)
|
||||
- All date comparisons in business logic
|
||||
- All timestamps in logs and error messages
|
||||
- All date parameters in queries
|
||||
|
||||
### 4. Git Operations - NEVER Without Explicit Permission
|
||||
**CRITICAL**: NEVER execute `git commit` or `git push` commands automatically. ALWAYS wait for explicit user instruction.
|
||||
|
||||
**Rules**:
|
||||
- Only commit when user explicitly says "commit" or "commit this"
|
||||
- Only push when user explicitly says "push" or "push to remote"
|
||||
- Stage files with `git add` ONLY when about to commit per user request
|
||||
|
||||
### 5. MediatR Command/Query File Organization
|
||||
**IMPORTANT**: Commands/Queries and their Handlers must be in the SAME file.
|
||||
|
||||
**Example**:
|
||||
```csharp
|
||||
// ✅ CORRECT - CreateEmailProfileCommand.cs contains BOTH
|
||||
public record CreateEmailProfileCommand : IRequest<int> { ... }
|
||||
|
||||
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int> { ... }
|
||||
|
||||
// ❌ WRONG - Separate files
|
||||
// CreateEmailProfileCommand.cs (command only)
|
||||
// CreateEmailProfileCommandHandler.cs (handler only)
|
||||
```
|
||||
|
||||
**File Naming**:
|
||||
- Commands: `{Verb}{Entity}Command.cs` (e.g., `CreateEmailProfileCommand.cs`)
|
||||
- Queries: `{Verb}{Entity}Query.cs` (e.g., `GetEmailProfilesQuery.cs`)
|
||||
|
||||
**Folder Structure** (NO Features/ prefix):
|
||||
```
|
||||
Application/
|
||||
├── EmailProfiles/
|
||||
│ ├── Commands/CreateEmailProfileCommand.cs
|
||||
│ ├── Queries/GetEmailProfilesQuery.cs
|
||||
│ └── Validators/CreateEmailProfileCommandValidator.cs
|
||||
├── EmailAccounts/
|
||||
│ ├── Commands/CreateEmailAccountCommand.cs
|
||||
│ └── Queries/GetEmailAccountsQuery.cs
|
||||
└── Common/
|
||||
├── Dtos/EmailProfileDto.cs (single DTOs at root)
|
||||
├── Dtos/EmailHistories/ (multiple DTOs in subfolder)
|
||||
└── Interfaces/IEmailService.cs
|
||||
```
|
||||
|
||||
### 6. Repository Pattern - NO UnitOfWork, Generic CRUD with AutoMapper
|
||||
**CRITICAL**: DO NOT use IUnitOfWork pattern. Use generic repository pattern with AutoMapper-based CRUD operations.
|
||||
|
||||
**Key Principles**:
|
||||
- ✅ Each operation auto-saves changes - NO explicit SaveChangesAsync needed
|
||||
- ✅ Use `UpdateSingleAsync` / `DeleteSingleAsync` for single-record safety
|
||||
- ✅ Use `UpdateAsync` / `DeleteAsync` only when intentionally modifying multiple records
|
||||
- ✅ AutoMapper handles all DTO → Entity mappings
|
||||
|
||||
**Pattern**:
|
||||
```csharp
|
||||
// IRepository<T> generic interface
|
||||
public interface IRepository<TEntity> where TEntity : class
|
||||
{
|
||||
// Query operations
|
||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, ...);
|
||||
|
||||
// Create - auto-saves
|
||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
// Update - auto-saves
|
||||
Task<int> UpdateAsync<TDto>(Expression<...> predicate, TDto dto, ...); // Multiple records
|
||||
Task UpdateSingleAsync<TDto>(Expression<...> predicate, TDto dto, ...); // SAFE: Single record only
|
||||
|
||||
// Delete - auto-saves
|
||||
Task<int> DeleteAsync(Expression<...> predicate, ...); // Multiple records
|
||||
Task DeleteSingleAsync(Expression<...> predicate, ...); // SAFE: Single record only
|
||||
}
|
||||
```
|
||||
|
||||
**Command Handler Examples**:
|
||||
```csharp
|
||||
// ✅ CORRECT - CreateAsync auto-saves
|
||||
public class CreateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||
: IRequestHandler<CreateEmailProfileCommand, int>
|
||||
{
|
||||
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await repository.CreateAsync(request, cancellationToken);
|
||||
return profile.Id; // NO SaveChangesAsync needed!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - UpdateSingleAsync for safety (throws if 0 or 2+ records match)
|
||||
public class UpdateEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||
: IRequestHandler<UpdateEmailProfileCommand, int>
|
||||
{
|
||||
public async Task<int> Handle(UpdateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await repository.UpdateSingleAsync(p => p.Id == request.Id, request, cancellationToken);
|
||||
return request.Id; // NO SaveChangesAsync needed!
|
||||
}
|
||||
}
|
||||
|
||||
// ✅ CORRECT - DeleteSingleAsync for safety (throws if 0 or 2+ records match)
|
||||
public class DeleteEmailProfileCommandHandler(IRepository<EmailProfile> repository)
|
||||
: IRequestHandler<DeleteEmailProfileCommand, int>
|
||||
{
|
||||
public async Task<int> Handle(DeleteEmailProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
await repository.DeleteSingleAsync(p => p.Id == request.Id, cancellationToken);
|
||||
return request.Id; // NO SaveChangesAsync needed!
|
||||
}
|
||||
}
|
||||
|
||||
// ❌ WRONG - Manual entity creation (use AutoMapper instead)
|
||||
var profile = new EmailProfile
|
||||
{
|
||||
ProfileName = request.ProfileName,
|
||||
EmailAccountId = request.EmailAccountId,
|
||||
// ... 15 more properties
|
||||
};
|
||||
|
||||
// ❌ WRONG - Using IUnitOfWork (removed)
|
||||
public CreateEmailProfileCommandHandler(IUnitOfWork unitOfWork) { ... }
|
||||
|
||||
// ❌ WRONG - Calling SaveChangesAsync (removed)
|
||||
await repository.SaveChangesAsync(cancellationToken);
|
||||
```
|
||||
|
||||
**Safety Rules**:
|
||||
1. **UpdateSingleAsync** - Use for ID-based updates. Throws `InvalidOperationException` if:
|
||||
- Zero records match (entity not found)
|
||||
- Multiple records match (predicate too broad)
|
||||
|
||||
2. **DeleteSingleAsync** - Use for ID-based deletes. Throws `InvalidOperationException` if:
|
||||
- Zero records match (entity not found)
|
||||
- Multiple records match (predicate too broad)
|
||||
|
||||
3. **UpdateAsync / DeleteAsync** - Use ONLY when intentionally modifying multiple records:
|
||||
```csharp
|
||||
// ✅ CORRECT - Intentional bulk operation
|
||||
await repository.UpdateAsync(
|
||||
p => p.EmailAccountId == accountId,
|
||||
new { IsActive = false },
|
||||
cancellationToken);
|
||||
|
||||
// ✅ Returns count of updated/deleted records
|
||||
var count = await repository.DeleteAsync(p => p.IsActive == false, cancellationToken);
|
||||
```
|
||||
|
||||
**DTO Mapping Responsibility**:
|
||||
- Each DTO creator must define their own AutoMapper profile
|
||||
- Example: `CreateEmailProfileCommand` → `EmailProfile` mapping must be defined in `EmailProfileMappingProfile.cs`
|
||||
- Repository implementation uses `IMapper.Map<TEntity>(dto)` internally
|
||||
|
||||
---
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### 7. RabbitMQ Command Bus Integration (IMPLEMENTED)
|
||||
|
||||
**Purpose**: Asynchronous command processing via RabbitMQ message broker for POST/PUT/DELETE operations.
|
||||
|
||||
**Architecture**:
|
||||
- **GET Queries**: Synchronous (immediate response via MediatR)
|
||||
- **POST/PUT/DELETE Commands**: Can be asynchronous (published to RabbitMQ, processed by background worker)
|
||||
|
||||
**RabbitMQ Server**:
|
||||
- Management UI: `http://172.24.12.56:15672`
|
||||
- AMQP Port: `5672` (default)
|
||||
- Exchange: `emailprofiler.commands` (Direct)
|
||||
- Queue: `emailprofiler.command.queue`
|
||||
- Routing Key: `command`
|
||||
|
||||
**Implementation Components**:
|
||||
|
||||
1. **ICommandPublisher** (`Application/Common/Interfaces/ICommandPublisher.cs`):
|
||||
- Interface for publishing commands to message broker
|
||||
- Generic method: `PublishAsync<TCommand>(TCommand command, CancellationToken)`
|
||||
|
||||
2. **RabbitMqCommandPublisher** (`Infrastructure/Messaging/RabbitMqCommandPublisher.cs`):
|
||||
- Implements `ICommandPublisher`
|
||||
- Serializes command to JSON with metadata envelope (CommandType, Payload, CorrelationId, PublishedAt)
|
||||
- Publishes to RabbitMQ exchange with persistent delivery mode
|
||||
|
||||
3. **RabbitMqCommandConsumer** (`Infrastructure/Messaging/RabbitMqCommandConsumer.cs`):
|
||||
- BackgroundService that consumes commands from RabbitMQ
|
||||
- Deserializes command envelope
|
||||
- Resolves command type from assembly
|
||||
- Executes command via MediatR in scoped service
|
||||
- Acknowledges message on success, requeues on error
|
||||
|
||||
4. **RabbitMqConfiguration** (`Infrastructure/Messaging/RabbitMqConfiguration.cs`):
|
||||
- Configuration model for RabbitMQ connection
|
||||
- Binds to `appsettings.json` section: `RabbitMq`
|
||||
|
||||
**Configuration** (`appsettings.json`):
|
||||
```json
|
||||
{
|
||||
"RabbitMq": {
|
||||
"HostName": "172.24.12.56",
|
||||
"Port": 5672,
|
||||
"UserName": "guest",
|
||||
"Password": "guest",
|
||||
"VirtualHost": "/",
|
||||
"ExchangeName": "emailprofiler.commands",
|
||||
"QueueName": "emailprofiler.command.queue",
|
||||
"RoutingKey": "command",
|
||||
"AutomaticRecoveryEnabled": true,
|
||||
"NetworkRecoveryIntervalSeconds": 10
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Dependency Injection** (`Infrastructure/DependencyInjection.cs`):
|
||||
```csharp
|
||||
services.Configure<RabbitMqConfiguration>(configuration.GetSection(RabbitMqConfiguration.SectionName));
|
||||
services.AddSingleton<ICommandPublisher, RabbitMqCommandPublisher>();
|
||||
services.AddHostedService<RabbitMqCommandConsumer>();
|
||||
```
|
||||
|
||||
**Usage in API Controllers** (Future):
|
||||
```csharp
|
||||
// Option 1: Synchronous (immediate execution via MediatR)
|
||||
var result = await _mediator.Send(new CreateEmailProfileCommand(...), cancellationToken);
|
||||
return Ok(result);
|
||||
|
||||
// Option 2: Asynchronous (publish to RabbitMQ for background processing)
|
||||
await _commandPublisher.PublishAsync(new CreateEmailProfileCommand(...), cancellationToken);
|
||||
return Accepted(); // HTTP 202 - command queued for processing
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Decouples API from long-running command processing
|
||||
- Improves API responsiveness (fire-and-forget)
|
||||
- Enables horizontal scaling (multiple consumers)
|
||||
- Automatic retries on failure (requeue mechanism)
|
||||
- Message persistence (survives application restarts)
|
||||
|
||||
---
|
||||
|
||||
### HIGH PRIORITY: Email Queue for Outgoing Messages (Future)
|
||||
|
||||
**Implementation Steps**:
|
||||
|
||||
1. **Add NuGet Package**:
|
||||
```bash
|
||||
dotnet add package RabbitMQ.Client
|
||||
```
|
||||
|
||||
2. **Create RabbitMqEmailQueue.cs**:
|
||||
```csharp
|
||||
// src/DigitalData.MessagingService.Infrastructure/Queue/RabbitMqEmailQueue.cs
|
||||
public class RabbitMqEmailQueue : IEmailQueue
|
||||
{
|
||||
private readonly IConnection _connection;
|
||||
private readonly IModel _channel;
|
||||
private const string QueueName = "email-outbox";
|
||||
|
||||
public RabbitMqEmailQueue(IOptions<RabbitMqConfiguration> config)
|
||||
{
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = config.Value.HostName,
|
||||
Port = config.Value.Port,
|
||||
UserName = config.Value.UserName,
|
||||
Password = config.Value.Password
|
||||
};
|
||||
|
||||
_connection = factory.CreateConnection();
|
||||
_channel = _connection.CreateModel();
|
||||
|
||||
_channel.QueueDeclare(
|
||||
queue: QueueName,
|
||||
durable: true,
|
||||
exclusive: false,
|
||||
autoDelete: false,
|
||||
arguments: null);
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(SendingEmail email, CancellationToken cancellationToken)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(email);
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var properties = _channel.CreateBasicProperties();
|
||||
properties.Persistent = true;
|
||||
|
||||
_channel.BasicPublish(
|
||||
exchange: "",
|
||||
routingKey: QueueName,
|
||||
basicProperties: properties,
|
||||
body: body);
|
||||
|
||||
await Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<SendingEmail?> DequeueAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
var result = _channel.BasicGet(QueueName, autoAck: false);
|
||||
|
||||
if (result == null)
|
||||
return null;
|
||||
|
||||
var json = Encoding.UTF8.GetString(result.Body.ToArray());
|
||||
var email = JsonSerializer.Deserialize<SendingEmail>(json);
|
||||
|
||||
_channel.BasicAck(result.DeliveryTag, false);
|
||||
|
||||
return await Task.FromResult(email);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
3. **Configuration** (appsettings.json):
|
||||
```json
|
||||
{
|
||||
"RabbitMq": {
|
||||
"HostName": "localhost",
|
||||
"Port": 5672,
|
||||
"UserName": "guest",
|
||||
"Password": "guest"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Dependency Injection** (Program.cs):
|
||||
```csharp
|
||||
// Replace InMemoryEmailQueue with RabbitMqEmailQueue
|
||||
// builder.Services.AddSingleton<IEmailQueue, InMemoryEmailQueue>();
|
||||
builder.Services.AddSingleton<IEmailQueue, RabbitMqEmailQueue>();
|
||||
```
|
||||
|
||||
**Benefits**:
|
||||
- Message persistence (survives application restarts)
|
||||
- Scalability (multiple worker instances can consume from queue)
|
||||
- Reliability (automatic retries, dead letter queues)
|
||||
- Monitoring (RabbitMQ management UI)
|
||||
|
||||
**Migration Path**:
|
||||
1. Deploy RabbitMQ server (Docker recommended)
|
||||
2. Test RabbitMqEmailQueue in staging environment
|
||||
3. Switch DI registration from InMemoryEmailQueue to RabbitMqEmailQueue
|
||||
4. Monitor queue depth and worker performance
|
||||
|
||||
---
|
||||
|
||||
## Pending Implementation Tasks
|
||||
|
||||
### Phase 2: Application Layer (COMPLETE)
|
||||
**Status**: ✅ Complete - All Commands, Queries, Handlers, Validators, AutoMapper Profiles, and Interfaces implemented
|
||||
|
||||
**Completed**:
|
||||
- ✅ MediatR Commands (CreateEmailProfileCommand, ProcessEmailCommand, etc.)
|
||||
- ✅ MediatR Queries (GetEmailProfilesQuery, GetEmailHistoryQuery, etc.)
|
||||
- ✅ Command/Query Handlers
|
||||
- ✅ FluentValidation Validators
|
||||
- ✅ AutoMapper Profiles
|
||||
- ✅ Application Interfaces (IEmailService, IPdfProcessingService, IDmsService, etc.)
|
||||
|
||||
**Example Command**:
|
||||
```csharp
|
||||
// src/DigitalData.MessagingService.Application/EmailProfiles/Commands/CreateEmailProfileCommand.cs
|
||||
public record CreateEmailProfileCommand(string ProfileName, int EmailAccountId) : IRequest<int>;
|
||||
|
||||
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int>
|
||||
{
|
||||
private readonly IRepository<EmailProfile> _repository;
|
||||
|
||||
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = await _repository.CreateAsync(request, cancellationToken);
|
||||
return profile.Id;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 8. Email Library - Limilabs Mail.dll
|
||||
|
||||
**IMPORTANT**: This project uses **Limilabs Mail.dll** (https://www.limilabs.com/) for email operations, NOT MailKit/MimeKit.
|
||||
|
||||
**Why Limilabs?**:
|
||||
- Commercial-grade IMAP/POP3/SMTP library
|
||||
- Better OAuth2 support (Microsoft 365, Gmail)
|
||||
- More reliable with Exchange servers
|
||||
- Superior attachment handling
|
||||
- Built-in retry mechanisms
|
||||
|
||||
**NuGet Package**:
|
||||
```bash
|
||||
dotnet add package Limilabs.Mail
|
||||
```
|
||||
|
||||
**Key Classes**:
|
||||
- `Imap` - IMAP client for receiving emails
|
||||
- `Smtp` - SMTP client for sending emails
|
||||
- `Mail.Message` - Email message representation
|
||||
- `OAuth2` - OAuth2 authentication helper
|
||||
|
||||
**Implementation Example**:
|
||||
```csharp
|
||||
// Limilabs IMAP with OAuth2
|
||||
using Limilabs.Client.IMAP;
|
||||
using Limilabs.Mail;
|
||||
|
||||
public class LimilabsEmailService : IEmailService
|
||||
{
|
||||
public async Task<IEnumerable<EmailMessage>> ReceiveEmailsAsync(EmailAccountDto account)
|
||||
{
|
||||
using var imap = new Imap();
|
||||
|
||||
if (account.UseOAuth2)
|
||||
{
|
||||
await imap.ConnectSSLAsync(account.ImapServer, account.ImapPort);
|
||||
await imap.LoginOAUTH2Async(account.Username, account.OAuth2AccessToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await imap.ConnectSSLAsync(account.ImapServer, account.ImapPort);
|
||||
await imap.LoginAsync(account.Username, account.EncryptedPassword);
|
||||
}
|
||||
|
||||
imap.SelectInbox();
|
||||
var uids = imap.Search(Flag.Unseen);
|
||||
|
||||
var messages = new List<EmailMessage>();
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
var eml = imap.GetMessageByUID(uid);
|
||||
var mail = new MailBuilder().CreateFromEml(eml);
|
||||
messages.Add(ConvertToEmailMessage(mail));
|
||||
}
|
||||
|
||||
imap.Close();
|
||||
return messages;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**DO NOT USE**:
|
||||
- ❌ MailKit
|
||||
- ❌ MimeKit
|
||||
- ❌ System.Net.Mail (obsolete)
|
||||
|
||||
### Phase 3: Infrastructure Layer (COMPLETE)
|
||||
**Status**: ✅ Complete - DbContext, Repository, Services, RabbitMQ, and DI implemented
|
||||
|
||||
**Completed**:
|
||||
- ✅ MessagingServiceDbContext with DbSet<T> for all entities (attribute-only config, no overrides)
|
||||
- ✅ Generic Repository<T> implementing IRepository<T> with AutoMapper-based CRUD
|
||||
- ✅ LimilabsEmailService (IMAP/SMTP with OAuth2 using Limilabs Mail.dll - TODO: Add Limilabs.Mail NuGet)
|
||||
- ✅ GdPicturePdfProcessingService (using GdPicture.NET 14 - TODO: Add GdPicture NuGet and license)
|
||||
- ✅ WindreamDmsService (COM Interop - TODO: Add windream COM Interop references)
|
||||
- ✅ DataProtectionEncryptionService (Data Protection API)
|
||||
- ✅ InMemoryEmailQueue (TODO: Upgrade to RabbitMqEmailQueue later)
|
||||
- ✅ RabbitMqCommandPublisher and RabbitMqCommandConsumer
|
||||
- ✅ DependencyInjection.cs with all service registrations
|
||||
|
||||
**Implementation Notes**:
|
||||
- All services have real implementations with commented TODO blocks for external dependencies
|
||||
- LimilabsEmailService uses Microsoft.Identity.Client for OAuth2 token acquisition
|
||||
- GdPicturePdfProcessingService uses GdPicture.NET 14.x API (GetAttachmentCount, ExtractEmbeddedFile)
|
||||
- WindreamDmsService uses COM Interop (WMSession, WMConnect, WMObjects) based on legacy patterns
|
||||
- NO EF Core migrations (legacy DB must not be modified)
|
||||
|
||||
**DbContext Example**:
|
||||
```csharp
|
||||
public class MessagingServiceDbContext : DbContext
|
||||
{
|
||||
public DbSet<EmailAccount> EmailAccounts { get; set; }
|
||||
public DbSet<EmailProfile> EmailProfiles { get; set; }
|
||||
// ... other DbSets
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// Important: Check for triggers
|
||||
modelBuilder.Entity<EmailHistory>().ToTable(tb => tb.HasTrigger("TR_TBEMLP_HISTORY_AUDIT"));
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 4: API Layer
|
||||
**Status**: Minimal structure exists
|
||||
|
||||
**TODO**:
|
||||
- [ ] Create Controllers (EmailProfilesController, EmailAccountsController, EmailHistoryController)
|
||||
- [ ] Create Background Workers (EmailPollingWorker, EmailSenderWorker)
|
||||
- [ ] Configure Serilog
|
||||
- [ ] Configure Scalar (OpenAPI documentation)
|
||||
- [ ] Add Exception Handling Middleware
|
||||
- [ ] Configure DI for all layers
|
||||
- [ ] Support both IIS and Windows Service hosting
|
||||
|
||||
**Worker Configuration** (appsettings.json):
|
||||
```json
|
||||
{
|
||||
"Workers": {
|
||||
"EmailPolling": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 60
|
||||
},
|
||||
"EmailSender": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 5
|
||||
}
|
||||
},
|
||||
"Hosting": {
|
||||
"Mode": "IIS" // or "WindowsService"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 5: Testing
|
||||
**Status**: Not started
|
||||
|
||||
**TODO**:
|
||||
- [ ] Unit tests for Domain entities
|
||||
- [ ] Unit tests for Application handlers (using FakeItEasy)
|
||||
- [ ] Integration tests for Repositories (using Testcontainers)
|
||||
- [ ] API tests (using WebApplicationFactory)
|
||||
- [ ] Generate fake test data (using Bogus)
|
||||
|
||||
**Test Example**:
|
||||
```csharp
|
||||
public class MessageIdGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generate_ShouldProduceSameHashAsLegacy()
|
||||
{
|
||||
// Arrange
|
||||
var generator = new MessageIdGenerator();
|
||||
var original = "msg-123";
|
||||
var sender = "test@example.com";
|
||||
var date = new DateTime(2026, 1, 1, 12, 0, 0);
|
||||
var subject = "Test Subject";
|
||||
|
||||
// Act
|
||||
var messageId = generator.Generate(original, sender, date, subject);
|
||||
|
||||
// Assert
|
||||
messageId.Hash.Should().NotBeNullOrEmpty();
|
||||
// TODO: Verify against known legacy hash
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture Decisions
|
||||
|
||||
### Clean Architecture Layers
|
||||
1. **Domain**: Core business logic, no dependencies
|
||||
2. **Application**: Use cases, depends on Domain
|
||||
3. **Infrastructure**: External concerns, depends on Domain + Application
|
||||
4. **API**: Entry point, depends on all
|
||||
|
||||
### CQRS Pattern with MediatR
|
||||
- **Commands**: Modify state (Create, Update, Delete)
|
||||
- **Queries**: Read data (Get, List)
|
||||
- Separate models for read and write operations
|
||||
|
||||
### Repository Pattern
|
||||
- Interface in Application layer
|
||||
- Implementation in Infrastructure layer
|
||||
- One repository per Aggregate Root
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
### 1. PdfSharp Embedded File Extraction
|
||||
PdfSharp has limited support for embedded file extraction from PDFs. If advanced PDF processing is needed, consider:
|
||||
- **iText7** (AGPL or commercial license)
|
||||
- **Aspose.PDF** (commercial license)
|
||||
- Custom PDF parsing using PDF specification
|
||||
|
||||
### 2. windream COM Interop
|
||||
The windream DMS integration uses COM Interop which is Windows-only. The application cannot be fully cross-platform unless windream provides a REST API alternative.
|
||||
|
||||
### 3. OAuth2 Token Refresh
|
||||
Current implementation acquires new tokens on each request. Consider implementing token caching:
|
||||
- Use `Microsoft.Identity.Web` for automatic token management
|
||||
- Cache tokens in memory or distributed cache (Redis)
|
||||
|
||||
---
|
||||
|
||||
## Development Guidelines
|
||||
|
||||
### 1. Code Style
|
||||
- All code and comments: **English**
|
||||
- README.md and user documentation: **German**
|
||||
- Follow C# naming conventions (PascalCase, camelCase)
|
||||
- Use nullable reference types (`#nullable enable`)
|
||||
|
||||
### 2. Logging
|
||||
Use Serilog with structured logging:
|
||||
```csharp
|
||||
_logger.LogInformation("Processing email {MessageId} from profile {ProfileId}", messageId, profileId);
|
||||
```
|
||||
|
||||
### 3. Configuration
|
||||
- Development: `appsettings.Development.json` + User Secrets
|
||||
- Production: `appsettings.json` + Environment Variables + Azure Key Vault
|
||||
|
||||
### 4. Error Handling
|
||||
- Domain: Throw `DomainException` for business rule violations
|
||||
- Application: Use `FluentValidation` for input validation
|
||||
- API: Use exception handling middleware to return proper HTTP status codes
|
||||
|
||||
---
|
||||
|
||||
## Deployment Scenarios
|
||||
|
||||
### IIS Hosting (Default)
|
||||
```json
|
||||
{
|
||||
"Hosting": {
|
||||
"Mode": "IIS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Windows Service Hosting
|
||||
```json
|
||||
{
|
||||
"Hosting": {
|
||||
"Mode": "WindowsService"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
In `Program.cs`:
|
||||
```csharp
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
if (builder.Configuration["Hosting:Mode"] == "WindowsService")
|
||||
{
|
||||
builder.Host.UseWindowsService();
|
||||
}
|
||||
```
|
||||
|
||||
Install as Windows Service:
|
||||
```bash
|
||||
sc create MessagingService binPath="C:\Path\To\DigitalData.MessagingService.API.exe"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contact and Support
|
||||
|
||||
For questions about this implementation, consult:
|
||||
- Legacy system analysis: `legacy/PROJECT_ANALYSIS.md`
|
||||
- Migration plan: `MIGRATION_PLAN.md` (if created)
|
||||
- This document: `agents.md`
|
||||
|
||||
---
|
||||
|
||||
**Last Updated**: 2026-07-07
|
||||
**Version**: 1.0
|
||||
**Status**: Phase 1 Complete (Domain Layer), Phase 2-8 Pending
|
||||
98
DigitalData.MessagingService.sln
Normal file
98
DigitalData.MessagingService.sln
Normal file
@@ -0,0 +1,98 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.14.36717.8
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{02EA681E-C7D8-13C7-8484-4AC65E1B71E8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DigitalData.MessagingService.Tests", "tests\DigitalData.MessagingService.Tests\DigitalData.MessagingService.Tests.csproj", "{211FB65F-2406-474E-A426-DA246B250AB8}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F20FEFD-9289-42C6-ABA6-8DB236D74559}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
agents.md = agents.md
|
||||
IMPLEMENTATION_GUIDE.md = IMPLEMENTATION_GUIDE.md
|
||||
README.md = README.md
|
||||
STATUS.md = STATUS.md
|
||||
EndProjectSection
|
||||
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
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{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}.Release|Any CPU.ActiveCfg = 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
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{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
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {90E29FDC-F6C6-414F-94BF-25DF61D18060}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
954
IMPLEMENTATION_GUIDE.md
Normal file
954
IMPLEMENTATION_GUIDE.md
Normal file
@@ -0,0 +1,954 @@
|
||||
# MessagingService - Implementation Guide for AI Agents
|
||||
|
||||
## Overview
|
||||
|
||||
This guide provides step-by-step instructions for AI agents to continue the implementation of the MessagingService application. The project is a modern .NET 8.0 rewrite of a legacy VB.NET email automation system.
|
||||
|
||||
---
|
||||
|
||||
## Current Status (2026-07-07)
|
||||
|
||||
✅ **COMPLETED**:
|
||||
- Domain Layer (100%)
|
||||
- All entities with proper `[Table]` and `[Column]` attributes
|
||||
- Value Objects (MessageId, EmailAddress)
|
||||
- Enums (ErrorCode, ProcessType, etc.)
|
||||
- Domain Services (MessageIdGenerator)
|
||||
- Domain Events (EmailProcessedEvent)
|
||||
- Exceptions (DomainException, ValidationException, AttachmentProcessingException)
|
||||
- agents.md documentation
|
||||
- Project builds successfully
|
||||
|
||||
🚧 **IN PROGRESS**:
|
||||
- Application Layer (5% - only DTOs created)
|
||||
|
||||
❌ **PENDING**:
|
||||
- Application Layer (95%)
|
||||
- Infrastructure Layer (0%)
|
||||
- API Layer (minimal structure only)
|
||||
- Testing (0%)
|
||||
- README.md documentation (0%)
|
||||
|
||||
---
|
||||
|
||||
## Architecture Overview
|
||||
|
||||
```
|
||||
DigitalData.MessagingService/
|
||||
├── src/
|
||||
│ ├── Domain/ ✅ COMPLETE
|
||||
│ ├── Application/ 🚧 IN PROGRESS (5%)
|
||||
│ ├── Infrastructure/ ❌ TODO
|
||||
│ └── API/ ❌ TODO (minimal structure exists)
|
||||
├── tests/
|
||||
│ └── Tests/ ❌ TODO
|
||||
├── legacy/ 📖 Reference only
|
||||
├── agents.md ✅ COMPLETE
|
||||
├── README.md ❌ TODO
|
||||
└── IMPLEMENTATION_GUIDE.md 📄 This file
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Phase-by-Phase Implementation Plan
|
||||
|
||||
### PHASE 2: Application Layer (Current Focus)
|
||||
|
||||
#### 2.1. Create Repository Interfaces
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/Interfaces/Repositories/`
|
||||
|
||||
Create these files:
|
||||
|
||||
**IEmailProfileRepository.cs**:
|
||||
```csharp
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
|
||||
|
||||
public interface IEmailProfileRepository
|
||||
{
|
||||
Task<EmailProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<List<EmailProfile>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<List<EmailProfile>> GetActiveProfilesAsync(CancellationToken cancellationToken = default);
|
||||
Task<List<EmailProfile>> GetProfilesDueForPollingAsync(CancellationToken cancellationToken = default);
|
||||
Task<int> AddAsync(EmailProfile profile, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(EmailProfile profile, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
**IEmailAccountRepository.cs**:
|
||||
```csharp
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
|
||||
|
||||
public interface IEmailAccountRepository
|
||||
{
|
||||
Task<EmailAccount?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<List<EmailAccount>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<List<EmailAccount>> GetActiveAccountsAsync(CancellationToken cancellationToken = default);
|
||||
Task<int> AddAsync(EmailAccount account, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(EmailAccount account, CancellationToken cancellationToken = default);
|
||||
Task DeleteAsync(int id, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
**IEmailHistoryRepository.cs**:
|
||||
```csharp
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Interfaces.Repositories;
|
||||
|
||||
public interface IEmailHistoryRepository
|
||||
{
|
||||
Task<EmailHistory?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<EmailHistory?> GetByMessageIdHashAsync(string hash, CancellationToken cancellationToken = default);
|
||||
Task<bool> ExistsAsync(string messageIdHash, CancellationToken cancellationToken = default);
|
||||
Task<List<EmailHistory>> GetByProfileIdAsync(int profileId, DateTime? from, DateTime? to, CancellationToken cancellationToken = default);
|
||||
Task<int> AddAsync(EmailHistory history, CancellationToken cancellationToken = default);
|
||||
Task UpdateAsync(EmailHistory history, CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
**IEmailProcessRepository.cs**, **IEmailOutboxRepository.cs** - Similar patterns.
|
||||
|
||||
#### 2.2. Create Service Interfaces
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/Interfaces/Services/`
|
||||
|
||||
**IEmailService.cs**:
|
||||
```csharp
|
||||
namespace DigitalData.MessagingService.Application.Interfaces.Services;
|
||||
|
||||
public interface IEmailService
|
||||
{
|
||||
Task<List<EmailMessage>> FetchUnreadEmailsAsync(
|
||||
EmailAccount account,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task<bool> TestConnectionAsync(
|
||||
EmailAccount account,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task SendEmailAsync(
|
||||
EmailAccount account,
|
||||
string recipient,
|
||||
string subject,
|
||||
string body,
|
||||
bool isHtml = true,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
Task DeleteEmailAsync(EmailAccount account, int imapUid, CancellationToken cancellationToken = default);
|
||||
Task MoveEmailAsync(EmailAccount account, int imapUid, string folderName, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public class EmailMessage
|
||||
{
|
||||
public int ImapUid { get; set; }
|
||||
public string MessageId { get; set; } = string.Empty;
|
||||
public string From { get; set; } = string.Empty;
|
||||
public string Subject { get; set; } = string.Empty;
|
||||
public DateTime Date { get; set; }
|
||||
public string BodyHtml { get; set; } = string.Empty;
|
||||
public string BodyText { get; set; } = string.Empty;
|
||||
public List<EmailAttachmentData> Attachments { get; set; } = new();
|
||||
public byte[] RawEmailData { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
|
||||
public class EmailAttachmentData
|
||||
{
|
||||
public string FileName { get; set; } = string.Empty;
|
||||
public string ContentType { get; set; } = string.Empty;
|
||||
public byte[] Data { get; set; } = Array.Empty<byte>();
|
||||
}
|
||||
```
|
||||
|
||||
**IPdfProcessingService.cs**, **IDmsService.cs**, **IEncryptionService.cs**, **IEmailQueue.cs** - See agents.md for examples.
|
||||
|
||||
#### 2.3. Create MediatR Commands
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Commands/`
|
||||
|
||||
**CreateEmailProfileCommand.cs**:
|
||||
```csharp
|
||||
using MediatR;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailProfiles.Commands;
|
||||
|
||||
public record CreateEmailProfileCommand(
|
||||
string ProfileName,
|
||||
int EmailAccountId,
|
||||
int? ProcessId,
|
||||
int PollIntervalMinutes) : IRequest<int>;
|
||||
|
||||
public class CreateEmailProfileCommandHandler : IRequestHandler<CreateEmailProfileCommand, int>
|
||||
{
|
||||
private readonly IEmailProfileRepository _repository;
|
||||
|
||||
public CreateEmailProfileCommandHandler(IEmailProfileRepository repository)
|
||||
{
|
||||
_repository = repository;
|
||||
}
|
||||
|
||||
public async Task<int> Handle(CreateEmailProfileCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var profile = new EmailProfile
|
||||
{
|
||||
ProfileName = request.ProfileName,
|
||||
EmailAccountId = request.EmailAccountId,
|
||||
ProcessId = request.ProcessId,
|
||||
PollIntervalMinutes = request.PollIntervalMinutes,
|
||||
IsActive = true,
|
||||
AddedWhen = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return await _repository.AddAsync(profile, cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**UpdateEmailProfileCommand.cs**, **DeleteEmailProfileCommand.cs**, **ActivateProfileCommand.cs** - Similar patterns.
|
||||
|
||||
#### 2.4. Create MediatR Queries
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Queries/`
|
||||
|
||||
**GetEmailProfilesQuery.cs**:
|
||||
```csharp
|
||||
using MediatR;
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailProfiles.Queries;
|
||||
|
||||
public record GetEmailProfilesQuery : IRequest<List<EmailProfileDto>>;
|
||||
|
||||
public class GetEmailProfilesQueryHandler : IRequestHandler<GetEmailProfilesQuery, List<EmailProfileDto>>
|
||||
{
|
||||
private readonly IEmailProfileRepository _repository;
|
||||
private readonly IMapper _mapper;
|
||||
|
||||
public GetEmailProfilesQueryHandler(IEmailProfileRepository repository, IMapper mapper)
|
||||
{
|
||||
_repository = repository;
|
||||
_mapper = mapper;
|
||||
}
|
||||
|
||||
public async Task<List<EmailProfileDto>> Handle(GetEmailProfilesQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var profiles = await _repository.GetAllAsync(cancellationToken);
|
||||
return _mapper.Map<List<EmailProfileDto>>(profiles);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**GetEmailProfileByIdQuery.cs**, **GetActiveProfilesQuery.cs**, **GetProfilesDueForPollingQuery.cs** - Similar patterns.
|
||||
|
||||
#### 2.5. Create Validators
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/EmailProfiles/Validators/`
|
||||
|
||||
**CreateEmailProfileCommandValidator.cs**:
|
||||
```csharp
|
||||
using FluentValidation;
|
||||
using DigitalData.MessagingService.Application.EmailProfiles.Commands;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.EmailProfiles.Validators;
|
||||
|
||||
public class CreateEmailProfileCommandValidator : AbstractValidator<CreateEmailProfileCommand>
|
||||
{
|
||||
public CreateEmailProfileCommandValidator()
|
||||
{
|
||||
RuleFor(x => x.ProfileName)
|
||||
.NotEmpty().WithMessage("Profile name is required")
|
||||
.MaximumLength(100).WithMessage("Profile name must not exceed 100 characters");
|
||||
|
||||
RuleFor(x => x.EmailAccountId)
|
||||
.GreaterThan(0).WithMessage("Email account ID must be greater than 0");
|
||||
|
||||
RuleFor(x => x.PollIntervalMinutes)
|
||||
.GreaterThan(0).WithMessage("Poll interval must be greater than 0")
|
||||
.LessThanOrEqualTo(1440).WithMessage("Poll interval must not exceed 1440 minutes (24 hours)");
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6. Create AutoMapper Profiles
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/Common/Mappings/`
|
||||
|
||||
**MappingProfile.cs**:
|
||||
```csharp
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Mappings;
|
||||
|
||||
public class MappingProfile : Profile
|
||||
{
|
||||
public MappingProfile()
|
||||
{
|
||||
// EmailProfile mappings
|
||||
CreateMap<EmailProfile, EmailProfileDto>()
|
||||
.ForMember(d => d.EmailAccountName, opt => opt.MapFrom(s => s.EmailAccount != null ? s.EmailAccount.AccountName : null))
|
||||
.ForMember(d => d.ProcessName, opt => opt.MapFrom(s => s.EmailProcess != null ? s.EmailProcess.ProcessName : null));
|
||||
|
||||
// EmailAccount mappings
|
||||
CreateMap<EmailAccount, EmailAccountDto>();
|
||||
|
||||
// EmailHistory mappings
|
||||
CreateMap<EmailHistory, EmailHistoryDto>()
|
||||
.ForMember(d => d.ProfileName, opt => opt.MapFrom(s => s.Profile != null ? s.Profile.ProfileName : null))
|
||||
.ForMember(d => d.Attachments, opt => opt.MapFrom(s => s.Attachments));
|
||||
|
||||
// EmailAttachment mappings
|
||||
CreateMap<EmailAttachment, EmailAttachmentDto>();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.7. Create DependencyInjection.cs
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Application/DependencyInjection.cs`
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using FluentValidation;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DigitalData.MessagingService.Application;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplication(this IServiceCollection services)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
// MediatR
|
||||
services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(assembly));
|
||||
|
||||
// AutoMapper
|
||||
services.AddAutoMapper(assembly);
|
||||
|
||||
// FluentValidation
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PHASE 3: Infrastructure Layer
|
||||
|
||||
#### 3.1. Add NuGet Packages
|
||||
|
||||
```bash
|
||||
cd src/DigitalData.MessagingService.Infrastructure
|
||||
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
|
||||
dotnet add package Microsoft.EntityFrameworkCore.Tools
|
||||
dotnet add package MailKit
|
||||
dotnet add package MimeKit
|
||||
dotnet add package PdfSharp
|
||||
dotnet add package Microsoft.Identity.Client
|
||||
dotnet add package Microsoft.AspNetCore.DataProtection
|
||||
```
|
||||
|
||||
#### 3.2. Create DbContext
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Infrastructure/Persistence/MessagingServiceDbContext.cs`
|
||||
|
||||
```csharp
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
|
||||
public class MessagingServiceDbContext : DbContext
|
||||
{
|
||||
public MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : base(options) { }
|
||||
|
||||
public DbSet<EmailAccount> EmailAccounts { get; set; }
|
||||
public DbSet<EmailProfile> EmailProfiles { get; set; }
|
||||
public DbSet<EmailProcess> EmailProcesses { get; set; }
|
||||
public DbSet<ProcessStep> ProcessSteps { get; set; }
|
||||
public DbSet<IndexingStep> IndexingSteps { get; set; }
|
||||
public DbSet<EmailHistory> EmailHistories { get; set; }
|
||||
public DbSet<EmailAttachment> EmailAttachments { get; set; }
|
||||
public DbSet<EmailOutbox> EmailOutbox { get; set; }
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
// Apply configurations from assembly (if you create IEntityTypeConfiguration classes)
|
||||
// modelBuilder.ApplyConfigurationsFromAssembly(Assembly.GetExecutingAssembly());
|
||||
|
||||
// Note: All entity configurations are already done via attributes in Domain entities
|
||||
// This is important - DO NOT modify database schema here!
|
||||
}
|
||||
|
||||
public override Task<int> SaveChangesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Auto-populate audit fields
|
||||
var entries = ChangeTracker.Entries<BaseEntity>();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
if (entry.State == EntityState.Added)
|
||||
{
|
||||
entry.Entity.CreatedDate = DateTime.UtcNow;
|
||||
entry.Entity.CreatedBy = "System"; // TODO: Get from current user context
|
||||
}
|
||||
|
||||
if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.ModifiedDate = DateTime.UtcNow;
|
||||
entry.Entity.ModifiedBy = "System"; // TODO: Get from current user context
|
||||
}
|
||||
}
|
||||
|
||||
return base.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 3.3. Create Repositories
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Infrastructure/Persistence/Repositories/`
|
||||
|
||||
**EmailProfileRepository.cs**:
|
||||
```csharp
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Application.Interfaces.Repositories;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Persistence.Repositories;
|
||||
|
||||
public class EmailProfileRepository : IEmailProfileRepository
|
||||
{
|
||||
private readonly MessagingServiceDbContext _context;
|
||||
|
||||
public EmailProfileRepository(MessagingServiceDbContext context)
|
||||
{
|
||||
_context = context;
|
||||
}
|
||||
|
||||
public async Task<EmailProfile?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.EmailProfiles
|
||||
.Include(p => p.EmailAccount)
|
||||
.Include(p => p.EmailProcess)
|
||||
.FirstOrDefaultAsync(p => p.Id == id, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<EmailProfile>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.EmailProfiles
|
||||
.Include(p => p.EmailAccount)
|
||||
.Include(p => p.EmailProcess)
|
||||
.OrderBy(p => p.Sequence)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<EmailProfile>> GetActiveProfilesAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await _context.EmailProfiles
|
||||
.Include(p => p.EmailAccount)
|
||||
.Include(p => p.EmailProcess)
|
||||
.Where(p => p.IsActive && p.EmailAccount!.IsActive)
|
||||
.OrderBy(p => p.Sequence)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<List<EmailProfile>> GetProfilesDueForPollingAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
|
||||
return await _context.EmailProfiles
|
||||
.Include(p => p.EmailAccount)
|
||||
.Include(p => p.EmailProcess)
|
||||
.Where(p => p.IsActive
|
||||
&& p.EmailAccount!.IsActive
|
||||
&& (!p.LastPollTime.HasValue ||
|
||||
EF.Functions.DateDiffMinute(p.LastPollTime.Value, now) >= p.PollIntervalMinutes))
|
||||
.OrderBy(p => p.Sequence)
|
||||
.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> AddAsync(EmailProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.EmailProfiles.Add(profile);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
return profile.Id;
|
||||
}
|
||||
|
||||
public async Task UpdateAsync(EmailProfile profile, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_context.EmailProfiles.Update(profile);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var profile = await GetByIdAsync(id, cancellationToken);
|
||||
if (profile != null)
|
||||
{
|
||||
_context.EmailProfiles.Remove(profile);
|
||||
await _context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Create similar repositories for **EmailAccountRepository**, **EmailHistoryRepository**, etc.
|
||||
|
||||
#### 3.4. Create External Services
|
||||
|
||||
**MailKitEmailService.cs**, **PdfSharpProcessingService.cs**, **WindreamDmsService.cs**, **EncryptionService.cs**, **InMemoryEmailQueue.cs**
|
||||
|
||||
(See agents.md for examples - these are complex services)
|
||||
|
||||
#### 3.5. Create DependencyInjection.cs
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs`
|
||||
|
||||
```csharp
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
using DigitalData.MessagingService.Application.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence.Repositories;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// DbContext
|
||||
services.AddDbContext<MessagingServiceDbContext>(options =>
|
||||
options.UseSqlServer(
|
||||
configuration.GetConnectionString("DefaultConnection"),
|
||||
sqlOptions =>
|
||||
{
|
||||
sqlOptions.EnableRetryOnFailure(
|
||||
maxRetryCount: 5,
|
||||
maxRetryDelay: TimeSpan.FromSeconds(30),
|
||||
errorNumbersToAdd: null);
|
||||
sqlOptions.CommandTimeout(60);
|
||||
}));
|
||||
|
||||
// Repositories
|
||||
services.AddScoped<IEmailProfileRepository, EmailProfileRepository>();
|
||||
services.AddScoped<IEmailAccountRepository, EmailAccountRepository>();
|
||||
services.AddScoped<IEmailHistoryRepository, EmailHistoryRepository>();
|
||||
// ... add other repositories
|
||||
|
||||
// External Services
|
||||
// services.AddScoped<IEmailService, MailKitEmailService>();
|
||||
// services.AddScoped<IPdfProcessingService, PdfSharpProcessingService>();
|
||||
// services.AddScoped<IDmsService, WindreamDmsService>();
|
||||
// services.AddScoped<IEncryptionService, DataProtectionEncryptionService>();
|
||||
// services.AddSingleton<IEmailQueue, InMemoryEmailQueue>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PHASE 4: API Layer
|
||||
|
||||
#### 4.1. Add NuGet Packages
|
||||
|
||||
```bash
|
||||
cd src/DigitalData.MessagingService.API
|
||||
dotnet add package Serilog.AspNetCore
|
||||
dotnet add package Serilog.Sinks.File
|
||||
dotnet add package Serilog.Sinks.MSSqlServer
|
||||
dotnet add package Scalar.AspNetCore
|
||||
```
|
||||
|
||||
#### 4.2. Update Program.cs
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.API/Program.cs`
|
||||
|
||||
```csharp
|
||||
using DigitalData.MessagingService.API;
|
||||
using DigitalData.MessagingService.Application;
|
||||
using DigitalData.MessagingService.Infrastructure;
|
||||
using Serilog;
|
||||
using Scalar.AspNetCore;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
// Configure Serilog
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.ReadFrom.Configuration(builder.Configuration)
|
||||
.Enrich.FromLogContext()
|
||||
.WriteTo.Console()
|
||||
.WriteTo.File("logs/emailprofiler-.log", rollingInterval: RollingInterval.Day)
|
||||
.CreateLogger();
|
||||
|
||||
builder.Host.UseSerilog();
|
||||
|
||||
// Check for Windows Service mode
|
||||
if (builder.Configuration["Hosting:Mode"] == "WindowsService")
|
||||
{
|
||||
builder.Host.UseWindowsService();
|
||||
}
|
||||
|
||||
// Add services
|
||||
builder.Services.AddControllers();
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
|
||||
// Add Application and Infrastructure layers
|
||||
builder.Services.AddApplication();
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
|
||||
// Add Background Workers
|
||||
// builder.Services.AddHostedService<EmailPollingWorker>();
|
||||
// builder.Services.AddHostedService<EmailSenderWorker>();
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
// Configure the HTTP request pipeline
|
||||
if (app.Environment.IsDevelopment())
|
||||
{
|
||||
app.UseSwagger();
|
||||
app.UseSwaggerUI();
|
||||
|
||||
// Add Scalar
|
||||
app.MapScalarApiReference();
|
||||
}
|
||||
|
||||
app.UseHttpsRedirection();
|
||||
app.UseAuthorization();
|
||||
app.MapControllers();
|
||||
|
||||
try
|
||||
{
|
||||
Log.Information("Starting MessagingService API");
|
||||
app.Run();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application start-up failed");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
```
|
||||
|
||||
#### 4.3. Create Controllers
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.API/Controllers/`
|
||||
|
||||
**EmailProfilesController.cs**:
|
||||
```csharp
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using MediatR;
|
||||
using DigitalData.MessagingService.Application.EmailProfiles.Commands;
|
||||
using DigitalData.MessagingService.Application.EmailProfiles.Queries;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EmailProfilesController : ControllerBase
|
||||
{
|
||||
private readonly IMediator _mediator;
|
||||
private readonly ILogger<EmailProfilesController> _logger;
|
||||
|
||||
public EmailProfilesController(IMediator mediator, ILogger<EmailProfilesController> logger)
|
||||
{
|
||||
_mediator = mediator;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
[HttpGet]
|
||||
public async Task<IActionResult> GetAll(CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new GetEmailProfilesQuery();
|
||||
var result = await _mediator.Send(query, cancellationToken);
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpGet("{id}")]
|
||||
public async Task<IActionResult> GetById(int id, CancellationToken cancellationToken)
|
||||
{
|
||||
var query = new GetEmailProfileByIdQuery(id);
|
||||
var result = await _mediator.Send(query, cancellationToken);
|
||||
|
||||
if (result == null)
|
||||
return NotFound();
|
||||
|
||||
return Ok(result);
|
||||
}
|
||||
|
||||
[HttpPost]
|
||||
public async Task<IActionResult> Create(CreateEmailProfileCommand command, CancellationToken cancellationToken)
|
||||
{
|
||||
var id = await _mediator.Send(command, cancellationToken);
|
||||
return CreatedAtAction(nameof(GetById), new { id }, id);
|
||||
}
|
||||
|
||||
// Add Update, Delete, Activate, Deactivate endpoints
|
||||
}
|
||||
```
|
||||
|
||||
Create similar controllers for **EmailAccountsController**, **EmailHistoryController**, **DashboardController**.
|
||||
|
||||
#### 4.4. Create Background Workers
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.API/Workers/`
|
||||
|
||||
**EmailPollingWorker.cs** and **EmailSenderWorker.cs** (See agents.md for implementation examples)
|
||||
|
||||
#### 4.5. Update appsettings.json
|
||||
|
||||
**Location**: `src/DigitalData.MessagingService.API/appsettings.json`
|
||||
|
||||
```json
|
||||
{
|
||||
"ConnectionStrings": {
|
||||
"DefaultConnection": "Server=(local);Database=DD_ECM;Integrated Security=true;TrustServerCertificate=true"
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Override": {
|
||||
"Microsoft": "Warning",
|
||||
"System": "Warning"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Workers": {
|
||||
"EmailPolling": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 60
|
||||
},
|
||||
"EmailSender": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 5
|
||||
}
|
||||
},
|
||||
"Hosting": {
|
||||
"Mode": "IIS"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### PHASE 5: Testing
|
||||
|
||||
#### 5.1. Add NuGet Packages
|
||||
|
||||
```bash
|
||||
cd tests/DigitalData.MessagingService.Tests
|
||||
dotnet add package FakeItEasy
|
||||
dotnet add package Bogus
|
||||
dotnet add package FluentAssertions
|
||||
dotnet add package Microsoft.AspNetCore.Mvc.Testing
|
||||
dotnet add package Testcontainers.MsSql
|
||||
```
|
||||
|
||||
#### 5.2. Create Unit Tests
|
||||
|
||||
**Location**: `tests/DigitalData.MessagingService.Tests/Unit/Domain/`
|
||||
|
||||
**MessageIdGeneratorTests.cs**:
|
||||
```csharp
|
||||
using Xunit;
|
||||
using FluentAssertions;
|
||||
using DigitalData.MessagingService.Domain.Services;
|
||||
|
||||
namespace DigitalData.MessagingService.Tests.Unit.Domain;
|
||||
|
||||
public class MessageIdGeneratorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Generate_ShouldCreateValidMessageId()
|
||||
{
|
||||
// Arrange
|
||||
var generator = new MessageIdGenerator();
|
||||
var original = "test-msg-123";
|
||||
var sender = "sender@example.com";
|
||||
var date = new DateTime(2026, 1, 1, 12, 0, 0);
|
||||
var subject = "Test Subject";
|
||||
|
||||
// Act
|
||||
var messageId = generator.Generate(original, sender, date, subject);
|
||||
|
||||
// Assert
|
||||
messageId.Should().NotBeNull();
|
||||
messageId.Hash.Should().NotBeNullOrEmpty();
|
||||
messageId.Value.Should().Contain(original);
|
||||
messageId.Value.Should().Contain(sender);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Generate_SameInput_ShouldProduceSameHash()
|
||||
{
|
||||
// Arrange
|
||||
var generator = new MessageIdGenerator();
|
||||
var original = "test-msg-123";
|
||||
var sender = "sender@example.com";
|
||||
var date = new DateTime(2026, 1, 1, 12, 0, 0);
|
||||
var subject = "Test Subject";
|
||||
|
||||
// Act
|
||||
var messageId1 = generator.Generate(original, sender, date, subject);
|
||||
var messageId2 = generator.Generate(original, sender, date, subject);
|
||||
|
||||
// Assert
|
||||
messageId1.Hash.Should().Be(messageId2.Hash);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 5.3. Create Integration Tests
|
||||
|
||||
Use Testcontainers for database integration tests.
|
||||
|
||||
---
|
||||
|
||||
### PHASE 6: Documentation
|
||||
|
||||
#### 6.1. Create README.md (in German)
|
||||
|
||||
**Location**: `README.md`
|
||||
|
||||
The README should include (in German):
|
||||
- Application overview
|
||||
- Architecture diagram
|
||||
- API endpoints documentation
|
||||
- Worker processes description
|
||||
- Database tables documentation
|
||||
- Configuration guide (appsettings.json)
|
||||
- Deployment instructions (IIS and Windows Service)
|
||||
- Troubleshooting guide
|
||||
|
||||
**Template structure**:
|
||||
```markdown
|
||||
# DigitalData MessagingService
|
||||
|
||||
## Übersicht
|
||||
[Application overview in German]
|
||||
|
||||
## Architektur
|
||||
[Architecture description]
|
||||
|
||||
## API Endpunkte
|
||||
|
||||
### Email Profile Management
|
||||
- GET /api/emailprofiles - Alle Profile abrufen
|
||||
- GET /api/emailprofiles/{id} - Profil nach ID abrufen
|
||||
- POST /api/emailprofiles - Neues Profil erstellen
|
||||
- PUT /api/emailprofiles/{id} - Profil aktualisieren
|
||||
- DELETE /api/emailprofiles/{id} - Profil löschen
|
||||
|
||||
[... continue for all controllers]
|
||||
|
||||
## Background Workers
|
||||
|
||||
### EmailPollingWorker
|
||||
Überwacht E-Mail-Konten und verarbeitet eingehende E-Mails.
|
||||
|
||||
**Konfiguration**:
|
||||
```json
|
||||
"Workers": {
|
||||
"EmailPolling": {
|
||||
"Enabled": true,
|
||||
"IntervalSeconds": 60
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
[... continue for all workers]
|
||||
|
||||
## Datenbank Tabellen
|
||||
|
||||
### TBDD_EMAIL_ACCOUNT
|
||||
[Table description]
|
||||
|
||||
[... continue for all tables]
|
||||
|
||||
## Konfiguration
|
||||
|
||||
[Detailed configuration guide]
|
||||
|
||||
## Deployment
|
||||
|
||||
### IIS Deployment
|
||||
[Step-by-step guide]
|
||||
|
||||
### Windows Service Deployment
|
||||
[Step-by-step guide]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Build and Test Commands
|
||||
|
||||
```bash
|
||||
# Build solution
|
||||
dotnet build
|
||||
|
||||
# Run tests
|
||||
dotnet test
|
||||
|
||||
# Run API
|
||||
cd src/DigitalData.MessagingService.API
|
||||
dotnet run
|
||||
|
||||
# Create migration
|
||||
cd src/DigitalData.MessagingService.Infrastructure
|
||||
dotnet ef migrations add InitialCreate --startup-project ../DigitalData.MessagingService.API
|
||||
|
||||
# Update database
|
||||
dotnet ef database update --startup-project ../DigitalData.MessagingService.API
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Important Reminders for AI Agents
|
||||
|
||||
1. **NEVER modify database schema** - use `[Table]` and `[Column]` attributes
|
||||
2. **NEVER commit to git** - wait for user instruction
|
||||
3. **All code and comments in English** - except README.md (German)
|
||||
4. **Use Serilog for logging** - structured logging
|
||||
5. **Worker intervals configurable** - via appsettings.json
|
||||
6. **Support IIS and Windows Service** - via configuration
|
||||
7. **Check for database triggers** - add to DbContext if they exist
|
||||
8. **RabbitMQ is future enhancement** - currently use InMemoryEmailQueue
|
||||
|
||||
---
|
||||
|
||||
## Next Steps for Continuation
|
||||
|
||||
1. Complete Application Layer (Commands, Queries, Validators)
|
||||
2. Complete Infrastructure Layer (DbContext, Repositories, Services)
|
||||
3. Complete API Layer (Controllers, Workers, Middleware)
|
||||
4. Create comprehensive tests
|
||||
5. Write README.md in German
|
||||
6. Build and test the complete application
|
||||
|
||||
---
|
||||
|
||||
**Document Version**: 1.0
|
||||
**Last Updated**: 2026-07-07
|
||||
**Status**: Phase 1 Complete, Phase 2-6 Pending
|
||||
302
README.md
302
README.md
@@ -1,2 +1,302 @@
|
||||
# DigitalData.EmailProfiler
|
||||
# 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`.
|
||||
|
||||
|
||||
|
||||
506
STATUS.md
Normal file
506
STATUS.md
Normal file
@@ -0,0 +1,506 @@
|
||||
# MessagingService - Implementation Status Report
|
||||
|
||||
**Last Updated**: 2026-07-20
|
||||
**Overall Progress**: 75% Complete (3 of 4 phases done)
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
The MessagingService migration from legacy VB.NET to modern C# .NET 8.0 Clean Architecture is **75% complete**. All core layers (Domain, Application, Infrastructure) are fully implemented with real service stubs ready for integration. Only API layer controllers and workers remain.
|
||||
|
||||
### ✅ What's Working
|
||||
- Complete Domain model with 8 entities mapped to legacy database
|
||||
- Full CQRS implementation with MediatR (5 Commands, 7 Queries)
|
||||
- Generic repository with AutoMapper-based CRUD
|
||||
- RabbitMQ integration for async command processing
|
||||
- Real service implementations (pending external dependencies)
|
||||
- Data Protection encryption service
|
||||
- Database context with legacy table mapping
|
||||
|
||||
### ⚠️ What's Missing
|
||||
- Limilabs.Mail NuGet package (for email operations)
|
||||
- GdPicture.NET 14 or DevExpress.Pdf NuGet (for PDF processing)
|
||||
- windream COM Interop DLLs (for DMS integration)
|
||||
- API Controllers and Background Workers
|
||||
- Unit and integration tests
|
||||
|
||||
---
|
||||
|
||||
## Phase Breakdown
|
||||
|
||||
### Phase 1: Domain Layer ✅ COMPLETE (100%)
|
||||
|
||||
**Entities** (8 total):
|
||||
- ✅ `EmailAccount` - Email server configuration (IMAP/SMTP/OAuth2)
|
||||
- ✅ `EmailProfile` - Email polling profiles with archiving rules
|
||||
- ✅ `EmailHistory` - Email import history with duplicate detection
|
||||
- ✅ `EmailAttachment` - Attachment metadata and file paths
|
||||
- ✅ `EmailFilterKeyword` - Keyword-based filtering rules
|
||||
- ✅ `EmailFilterRule` - Sender/recipient filtering rules
|
||||
- ✅ `WindreamArchive` - windream DMS archive metadata
|
||||
- ✅ `LogEmailOut` - Outgoing email queue
|
||||
|
||||
**Value Objects** (3 total):
|
||||
- ✅ `MessageId` - SHA256-based message ID with duplicate detection
|
||||
- ✅ `EmailAddress` - Validated email address with display name
|
||||
- ✅ `FilePathValue` - Validated file system paths
|
||||
|
||||
**Enums** (5 total):
|
||||
- ✅ `ArchiveMode` - Email archiving strategies
|
||||
- ✅ `EmailAccountType` - Account types (Exchange/IMAP/Office365)
|
||||
- ✅ `EmailProtocol` - Email protocols (POP3/IMAP)
|
||||
- ✅ `FilterActionType` - Filter actions (Delete/MoveFolder)
|
||||
- ✅ `ProcessingStatus` - Processing states (Pending/Success/Error)
|
||||
|
||||
**Domain Events** (2 total):
|
||||
- ✅ `EmailProcessedEvent` - Published after successful email processing
|
||||
- ✅ `EmailArchivedEvent` - Published after windream archiving
|
||||
|
||||
**Domain Services** (1 total):
|
||||
- ✅ `MessageIdGenerator` - Generates SHA256 message IDs (legacy-compatible)
|
||||
|
||||
**Key Features**:
|
||||
- All entities use `[Table]` and `[Column]` attributes for legacy database mapping
|
||||
- NO database modifications allowed (read-only schema)
|
||||
- DateTime fields use `DateTime.Now` (local server time, not UTC)
|
||||
- Entities handle all configuration (NO Fluent API in DbContext)
|
||||
|
||||
**Files**:
|
||||
```
|
||||
src/DigitalData.MessagingService.Domain/
|
||||
├── Entities/ (8 files)
|
||||
├── ValueObjects/ (3 files)
|
||||
├── Enums/ (5 files)
|
||||
├── Events/ (2 files)
|
||||
└── Services/ (1 file)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: Application Layer ✅ COMPLETE (100%)
|
||||
|
||||
**Commands** (5 total):
|
||||
- ✅ `CreateEmailProfileCommand` - Create new email profile
|
||||
- ✅ `UpdateEmailProfileCommand` - Update existing profile
|
||||
- ✅ `DeleteEmailProfileCommand` - Delete profile
|
||||
- ✅ `CreateEmailAccountCommand` - Create email account
|
||||
- ✅ `ProcessEmailCommand` - Process incoming email
|
||||
|
||||
**Queries** (7 total):
|
||||
- ✅ `GetEmailProfilesQuery` - Get all profiles
|
||||
- ✅ `GetEmailProfileByIdQuery` - Get profile by ID
|
||||
- ✅ `GetEmailAccountsQuery` - Get all accounts
|
||||
- ✅ `GetEmailAccountByIdQuery` - Get account by ID
|
||||
- ✅ `GetEmailHistoryQuery` - Get email history with filters
|
||||
- ✅ `GetWindreamArchivesQuery` - Get windream archives
|
||||
- ✅ `GetLogEmailOutQuery` - Get outgoing email queue
|
||||
|
||||
**Validators** (4 total):
|
||||
- ✅ `CreateEmailProfileCommandValidator` - FluentValidation for CreateEmailProfileCommand
|
||||
- ✅ `UpdateEmailProfileCommandValidator` - FluentValidation for UpdateEmailProfileCommand
|
||||
- ✅ `CreateEmailAccountCommandValidator` - FluentValidation for CreateEmailAccountCommand
|
||||
- ✅ `ProcessEmailCommandValidator` - FluentValidation for ProcessEmailCommand
|
||||
|
||||
**AutoMapper Profiles** (4 total):
|
||||
- ✅ `EmailProfileMappingProfile` - Maps EmailProfile DTOs ↔ Entities
|
||||
- ✅ `EmailAccountMappingProfile` - Maps EmailAccount DTOs ↔ Entities
|
||||
- ✅ `EmailHistoryMappingProfile` - Maps EmailHistory DTOs ↔ Entities
|
||||
- ✅ `WindreamArchiveMappingProfile` - Maps WindreamArchive DTOs ↔ Entities
|
||||
|
||||
**DTOs** (8 total):
|
||||
- ✅ `EmailAccountDto` - Email account configuration
|
||||
- ✅ `EmailProfileDto` - Email profile configuration
|
||||
- ✅ `CreateEmailProfileDto` - Create profile request
|
||||
- ✅ `UpdateEmailProfileDto` - Update profile request
|
||||
- ✅ `EmailHistoryDto` - Email history record
|
||||
- ✅ `EmailAttachmentDto` - Attachment metadata
|
||||
- ✅ `WindreamArchiveDto` - windream archive record
|
||||
- ✅ `LogEmailOutDto` - Outgoing email record
|
||||
|
||||
**Interfaces** (6 total):
|
||||
- ✅ `IRepository<T>` - Generic repository with AutoMapper CRUD
|
||||
- ✅ `IEmailService` - Email operations (IMAP/SMTP/OAuth2)
|
||||
- ✅ `IPdfProcessingService` - PDF validation and embedded file extraction
|
||||
- ✅ `IDmsService` - windream DMS integration
|
||||
- ✅ `IEncryptionService` - Encryption/decryption for passwords
|
||||
- ✅ `IEmailQueue` - Outgoing email queue
|
||||
|
||||
**Key Features**:
|
||||
- Commands/Queries/Handlers in SAME file (MediatR pattern)
|
||||
- AutoMapper-based repository operations (no manual mapping)
|
||||
- FluentValidation for all commands
|
||||
- Direct folder structure: `Application/{Entity}/Commands`, `Application/{Entity}/Queries` (NO Features/ parent)
|
||||
- DTOs organized: Single DTOs at root (`Common/Dtos/EmailAccountDto.cs`), Multiple DTOs in subfolders (`Common/Dtos/EmailHistories/`)
|
||||
|
||||
**Files**:
|
||||
```
|
||||
src/DigitalData.MessagingService.Application/
|
||||
├── EmailProfiles/Commands/ (3 files)
|
||||
├── EmailProfiles/Queries/ (2 files)
|
||||
├── EmailProfiles/Validators/ (2 files)
|
||||
├── EmailAccounts/Commands/ (1 file)
|
||||
├── EmailAccounts/Queries/ (2 files)
|
||||
├── EmailAccounts/Validators/ (1 file)
|
||||
├── EmailProcessing/Commands/ (1 file)
|
||||
├── EmailProcessing/Validators/ (1 file)
|
||||
├── EmailHistory/Queries/ (1 file)
|
||||
├── WindreamArchives/Queries/ (1 file)
|
||||
├── LogEmailOut/Queries/ (1 file)
|
||||
└── Common/
|
||||
├── Dtos/ (8 files)
|
||||
├── Interfaces/ (6 files)
|
||||
└── Mappings/ (4 files)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: Infrastructure Layer ✅ COMPLETE (100%)
|
||||
|
||||
**Database**:
|
||||
- ✅ `MessagingServiceDbContext` - EF Core DbContext with 8 DbSets
|
||||
- NO `OnModelCreating` override (attribute-only configuration)
|
||||
- NO `SaveChangesAsync` override (Repository handles this)
|
||||
- Connection string: `DefaultConnection` from appsettings
|
||||
|
||||
**Repository**:
|
||||
- ✅ `Repository<T>` - Generic repository implementing `IRepository<T>`
|
||||
- AutoMapper-based CRUD: `CreateAsync<TDto>`, `UpdateAsync<TDto>`, `DeleteAsync`
|
||||
- Safe single-record operations: `UpdateSingleAsync`, `DeleteSingleAsync` (throw if 0 or 2+ records)
|
||||
- Query methods: `GetByIdAsync`, `GetAllAsync`, `FindAsync`, `FindFirstAsync`, `FindSingleAsync`
|
||||
- All operations auto-save changes (NO explicit SaveChangesAsync needed)
|
||||
|
||||
**Services** (6 total):
|
||||
- ✅ `LimilabsEmailService` - Email operations using Limilabs Mail.dll
|
||||
- IMAP: `ConnectSSLAsync`, `LoginOAUTH2Async`, `Search(Flag.Unseen)`, `GetMessageByUID`
|
||||
- SMTP: `SendMessageAsync`
|
||||
- OAuth2: `GetOAuth2TokenAsync` via `Microsoft.Identity.Client` (MSAL)
|
||||
- **TODO**: Add Limilabs.Mail NuGet package to uncomment implementation
|
||||
|
||||
- ✅ `GdPicturePdfProcessingService` - PDF processing using GdPicture.NET 14
|
||||
- `ValidatePdfAsync` - PDF validation
|
||||
- `ExtractEmbeddedFilesAsync` - Extract embedded files via `GetAttachmentCount`, `ExtractEmbeddedFile`
|
||||
- `GetPageCountAsync` - Get PDF page count
|
||||
- **TODO**: Add GdPicture.NET.14 NuGet package and license key
|
||||
|
||||
- ✅ `WindreamDmsService` - windream DMS integration using COM Interop
|
||||
- `ImportDocumentAsync` - Import document with metadata (WMSession, WMConnect, WMObjects)
|
||||
- `DocumentExistsAsync` - Check if document exists
|
||||
- `UpdateMetadataAsync` - Update document metadata
|
||||
- **TODO**: Add windream COM Interop DLL references (WINDREAMLib, WMOBRWSLib)
|
||||
|
||||
- ✅ `DataProtectionEncryptionService` - Encryption using ASP.NET Core Data Protection
|
||||
- `Encrypt(plainText)` - Encrypt passwords/secrets
|
||||
- `Decrypt(cipherText)` - Decrypt passwords/secrets
|
||||
|
||||
- ✅ `InMemoryEmailQueue` - Temporary in-memory queue for outgoing emails
|
||||
- `EnqueueAsync` - Add email to queue
|
||||
- `DequeueAsync` - Get next email from queue
|
||||
- **TODO**: Replace with `RabbitMqEmailQueue` for production
|
||||
|
||||
- ✅ `RabbitMqCommandPublisher` - Publishes commands to RabbitMQ
|
||||
- Implements `ICommandPublisher`
|
||||
- Serializes commands to JSON with metadata envelope
|
||||
- Publishes to `emailprofiler.commands` exchange
|
||||
|
||||
- ✅ `RabbitMqCommandConsumer` - Consumes commands from RabbitMQ (BackgroundService)
|
||||
- Consumes from `emailprofiler.command.queue`
|
||||
- Deserializes and executes commands via MediatR
|
||||
- Acknowledges or requeues messages
|
||||
|
||||
**Configuration**:
|
||||
- ✅ `RabbitMqConfiguration` - RabbitMQ connection settings (binds to `appsettings.json`)
|
||||
|
||||
**Dependency Injection**:
|
||||
- ✅ `DependencyInjection.cs` - Infrastructure service registration
|
||||
- DbContext with SQL Server retry policy
|
||||
- Generic repository (scoped)
|
||||
- All services (scoped)
|
||||
- RabbitMQ publisher (singleton) and consumer (hosted service)
|
||||
- Data Protection with default key storage
|
||||
|
||||
**NuGet Packages**:
|
||||
- ✅ Microsoft.EntityFrameworkCore.SqlServer 8.0.11
|
||||
- ✅ Microsoft.EntityFrameworkCore.Tools 8.0.11
|
||||
- ✅ Microsoft.AspNetCore.DataProtection 8.0.11
|
||||
- ✅ Microsoft.Identity.Client 4.65.0
|
||||
- ✅ AutoMapper 12.0.1 (warning: vulnerability in 12.0.0-12.0.1 - acceptable for internal use)
|
||||
- ✅ RabbitMQ.Client 7.2.1
|
||||
- ⚠️ Limilabs.Mail (NOT YET ADDED - required for LimilabsEmailService)
|
||||
- ⚠️ GdPicture.NET.14 (NOT YET ADDED - required for GdPicturePdfProcessingService)
|
||||
|
||||
**Files**:
|
||||
```
|
||||
src/DigitalData.MessagingService.Infrastructure/
|
||||
├── Persistence/MessagingServiceDbContext.cs
|
||||
├── Repositories/Repository.cs
|
||||
├── Services/ (6 files)
|
||||
├── Messaging/ (3 files)
|
||||
├── Queue/InMemoryEmailQueue.cs
|
||||
└── DependencyInjection.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: API Layer ⚠️ IN PROGRESS (30%)
|
||||
|
||||
**Controllers** (3 total):
|
||||
- ✅ `EmailProfilesController` - CRUD operations for email profiles
|
||||
- GET /api/emailprofiles - Get all profiles (synchronous via MediatR)
|
||||
- GET /api/emailprofiles/{id} - Get profile by ID
|
||||
- POST /api/emailprofiles - Create profile (async via RabbitMQ, returns HTTP 202)
|
||||
- PUT /api/emailprofiles/{id} - Update profile (async via RabbitMQ, returns HTTP 202)
|
||||
- DELETE /api/emailprofiles/{id} - Delete profile (async via RabbitMQ, returns HTTP 202)
|
||||
|
||||
- ✅ `EmailAccountsController` - CRUD operations for email accounts
|
||||
- GET /api/emailaccounts - Get all accounts
|
||||
- GET /api/emailaccounts/{id} - Get account by ID
|
||||
- POST /api/emailaccounts - Create account (async via RabbitMQ)
|
||||
|
||||
- ✅ `EmailHistoryController` - Query email history
|
||||
- GET /api/emailhistory - Get email history with filters
|
||||
|
||||
**Workers** (Background Services):
|
||||
- ❌ `EmailPollingWorker` - Polls email accounts for new messages (NOT STARTED)
|
||||
- ❌ `EmailSenderWorker` - Sends outgoing emails from queue (NOT STARTED)
|
||||
|
||||
**Configuration**:
|
||||
- ✅ `appsettings.json` - Application configuration
|
||||
- ✅ `appsettings.Secrets.json` - External secrets file (ignored by Git)
|
||||
- ✅ RabbitMQ configuration section
|
||||
- ❌ Serilog configuration (NOT CONFIGURED)
|
||||
- ❌ Worker configuration (NOT CONFIGURED)
|
||||
|
||||
**Middleware**:
|
||||
- ❌ Exception Handling Middleware (NOT IMPLEMENTED)
|
||||
- ❌ Request Logging Middleware (NOT IMPLEMENTED)
|
||||
|
||||
**Documentation**:
|
||||
- ❌ Scalar OpenAPI documentation (NOT CONFIGURED)
|
||||
|
||||
**TODO**:
|
||||
- [ ] Create `EmailPollingWorker` - Background service to poll email accounts
|
||||
- [ ] Create `EmailSenderWorker` - Background service to send outgoing emails
|
||||
- [ ] Configure Serilog for structured logging
|
||||
- [ ] Configure Scalar for OpenAPI documentation
|
||||
- [ ] Add exception handling middleware
|
||||
- [ ] Add request logging middleware
|
||||
- [ ] Add worker configuration to `appsettings.json`
|
||||
- [ ] Add IIS and Windows Service hosting support
|
||||
|
||||
**Files**:
|
||||
```
|
||||
src/DigitalData.MessagingService.API/
|
||||
├── Controllers/ (3 files)
|
||||
├── appsettings.json
|
||||
├── appsettings.Secrets.json
|
||||
└── Program.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: Testing ❌ NOT STARTED (0%)
|
||||
|
||||
**TODO**:
|
||||
- [ ] Unit tests for Domain entities (MessageIdGenerator, Value Objects)
|
||||
- [ ] Unit tests for Application handlers (using FakeItEasy for mocks)
|
||||
- [ ] Integration tests for Repository (using Testcontainers for SQL Server)
|
||||
- [ ] Integration tests for EmailService (using test email account)
|
||||
- [ ] API tests (using WebApplicationFactory)
|
||||
- [ ] Generate fake test data (using Bogus library)
|
||||
|
||||
**Test Structure**:
|
||||
```
|
||||
tests/DigitalData.MessagingService.Tests/
|
||||
├── Domain/
|
||||
│ ├── Services/MessageIdGeneratorTests.cs
|
||||
│ ├── ValueObjects/EmailAddressTests.cs
|
||||
│ └── ValueObjects/MessageIdTests.cs
|
||||
├── Application/
|
||||
│ ├── EmailProfiles/CreateEmailProfileCommandHandlerTests.cs
|
||||
│ ├── EmailProfiles/GetEmailProfilesQueryHandlerTests.cs
|
||||
│ └── EmailProcessing/ProcessEmailCommandHandlerTests.cs
|
||||
├── Infrastructure/
|
||||
│ ├── Repositories/RepositoryTests.cs
|
||||
│ ├── Services/LimilabsEmailServiceTests.cs
|
||||
│ └── Services/GdPicturePdfProcessingServiceTests.cs
|
||||
└── API/
|
||||
├── Controllers/EmailProfilesControllerTests.cs
|
||||
└── Workers/EmailPollingWorkerTests.cs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## External Dependencies Status
|
||||
|
||||
### 1. Limilabs.Mail ⚠️ REQUIRED
|
||||
**Status**: Not added
|
||||
**Action**: `dotnet add package Limilabs.Mail`
|
||||
**Impact**: Email operations (IMAP/SMTP/OAuth2) will not work
|
||||
**Files Affected**: `LimilabsEmailService.cs`
|
||||
|
||||
### 2. GdPicture.NET 14 ⚠️ REQUIRED
|
||||
**Status**: Not added
|
||||
**Action**: Add GdPicture.NET.14 NuGet package + license key
|
||||
**Impact**: PDF processing and embedded file extraction will not work
|
||||
**Files Affected**: `GdPicturePdfProcessingService.cs`
|
||||
**Alternative**: Use DevExpress.Pdf (already licensed)
|
||||
|
||||
### 3. windream COM Interop ⚠️ REQUIRED
|
||||
**Status**: DLLs not referenced
|
||||
**Action**: Add COM references for WINDREAMLib, WMOBRWSLib
|
||||
**Impact**: windream DMS archiving will not work
|
||||
**Files Affected**: `WindreamDmsService.cs`
|
||||
**Legacy Path**: `M:\Bibliotheken\3rdParty\windream\Interop.WINDREAMLib.dll`
|
||||
|
||||
### 4. RabbitMQ Server ✅ AVAILABLE
|
||||
**Status**: Running at `172.24.12.56:5672`
|
||||
**Management UI**: `http://172.24.12.56:15672`
|
||||
**Action**: None - already configured
|
||||
**Files Affected**: `RabbitMqCommandPublisher.cs`, `RabbitMqCommandConsumer.cs`
|
||||
|
||||
### 5. SQL Server Database ✅ AVAILABLE
|
||||
**Status**: Legacy database exists
|
||||
**Action**: Update connection string in `appsettings.Secrets.json`
|
||||
**Files Affected**: `MessagingServiceDbContext.cs`
|
||||
|
||||
---
|
||||
|
||||
## Build Status
|
||||
|
||||
**Last Build**: 2026-07-20
|
||||
**Result**: ✅ Success
|
||||
**Warnings**: 1
|
||||
**Errors**: 0
|
||||
|
||||
**Warnings**:
|
||||
- `CS9113`: Parameter 'dmsService' is unread in `ProcessEmailCommandHandler`
|
||||
- **Reason**: Service implementation pending windream COM Interop integration
|
||||
- **Action**: Will be used when windream integration is complete
|
||||
|
||||
**Build Command**:
|
||||
```bash
|
||||
dotnet build src/DigitalData.MessagingService.Infrastructure/DigitalData.MessagingService.Infrastructure.csproj
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Next Steps (Priority Order)
|
||||
|
||||
### 1. Add External Dependencies (HIGH PRIORITY)
|
||||
- [ ] Add Limilabs.Mail NuGet package
|
||||
- [ ] Add GdPicture.NET 14 (or DevExpress.Pdf) NuGet package
|
||||
- [ ] Add windream COM Interop DLL references
|
||||
- [ ] Uncomment service implementations once dependencies are available
|
||||
|
||||
### 2. Complete API Layer (HIGH PRIORITY)
|
||||
- [ ] Create `EmailPollingWorker` background service
|
||||
- [ ] Create `EmailSenderWorker` background service
|
||||
- [ ] Configure Serilog for structured logging
|
||||
- [ ] Configure Scalar for OpenAPI documentation
|
||||
- [ ] Add exception handling middleware
|
||||
- [ ] Test API endpoints with Postman/Swagger
|
||||
|
||||
### 3. Integration Testing (MEDIUM PRIORITY)
|
||||
- [ ] Set up test SQL Server database (or use Testcontainers)
|
||||
- [ ] Write repository integration tests
|
||||
- [ ] Write email service integration tests (with test account)
|
||||
- [ ] Write API integration tests
|
||||
|
||||
### 4. Unit Testing (MEDIUM PRIORITY)
|
||||
- [ ] Write Domain entity tests
|
||||
- [ ] Write Application handler tests (with FakeItEasy mocks)
|
||||
- [ ] Write validation tests
|
||||
|
||||
### 5. Deployment Preparation (LOW PRIORITY)
|
||||
- [ ] Configure IIS hosting
|
||||
- [ ] Configure Windows Service hosting
|
||||
- [ ] Set up production appsettings
|
||||
- [ ] Configure Azure Key Vault (if needed)
|
||||
- [ ] Create deployment scripts
|
||||
|
||||
---
|
||||
|
||||
## Known Issues and Limitations
|
||||
|
||||
### 1. AutoMapper Vulnerability Warning
|
||||
**Issue**: NuGet package `AutoMapper 12.0.1` has a known vulnerability
|
||||
**Severity**: Moderate (only affects 12.0.0-12.0.1)
|
||||
**Impact**: Internal application - acceptable risk
|
||||
**Resolution**: Upgrade to AutoMapper 13.0+ when stable
|
||||
|
||||
### 2. RabbitMQ Email Queue Not Implemented
|
||||
**Issue**: Using `InMemoryEmailQueue` instead of `RabbitMqEmailQueue`
|
||||
**Impact**: Outgoing emails lost on application restart
|
||||
**Resolution**: Implement `RabbitMqEmailQueue` before production deployment
|
||||
|
||||
### 3. No Database Migrations
|
||||
**Issue**: EF Core migrations disabled (legacy database must not be modified)
|
||||
**Impact**: Cannot use `dotnet ef database update`
|
||||
**Resolution**: All schema changes must be done manually in legacy system
|
||||
|
||||
### 4. DateTime.Now vs DateTime.UtcNow
|
||||
**Issue**: Must use `DateTime.Now` (local server time) throughout application
|
||||
**Impact**: Non-standard practice (industry standard is UTC)
|
||||
**Reason**: Legacy database stores local time, not UTC
|
||||
**Resolution**: Document clearly and enforce in code reviews
|
||||
|
||||
### 5. windream COM Interop Windows-Only
|
||||
**Issue**: windream DMS integration uses COM Interop (Windows-only)
|
||||
**Impact**: Application cannot be deployed on Linux/Docker
|
||||
**Resolution**: windream must provide REST API, or accept Windows-only deployment
|
||||
|
||||
---
|
||||
|
||||
## Documentation
|
||||
|
||||
### Files Created
|
||||
- ✅ `AGENTS.md` - Agent notes, decisions, and future enhancements
|
||||
- ✅ `STATUS.md` - This file - implementation status report
|
||||
- ✅ `README.md` - Project overview and getting started guide (German)
|
||||
|
||||
### Code Documentation
|
||||
- ✅ XML comments on all public classes, methods, and properties
|
||||
- ✅ TODO comments in service implementations for external dependencies
|
||||
- ✅ Example usage in command/query handlers
|
||||
|
||||
---
|
||||
|
||||
## Team Handoff Notes
|
||||
|
||||
### For Developers Continuing This Project
|
||||
|
||||
**What You Can Do Right Now**:
|
||||
1. Build the solution: `dotnet build`
|
||||
2. Review the Domain layer: `src/DigitalData.MessagingService.Domain/`
|
||||
3. Review the Application layer: `src/DigitalData.MessagingService.Application/`
|
||||
4. Review the Infrastructure layer: `src/DigitalData.MessagingService.Infrastructure/`
|
||||
5. Review the API layer: `src/DigitalData.MessagingService.API/`
|
||||
|
||||
**What You Need to Complete**:
|
||||
1. Add Limilabs.Mail NuGet package: `dotnet add package Limilabs.Mail`
|
||||
2. Add GdPicture.NET 14 or DevExpress.Pdf NuGet package
|
||||
3. Add windream COM Interop DLL references (from legacy project)
|
||||
4. Uncomment service implementations in:
|
||||
- `LimilabsEmailService.cs`
|
||||
- `GdPicturePdfProcessingService.cs`
|
||||
- `WindreamDmsService.cs`
|
||||
5. Create background workers:
|
||||
- `EmailPollingWorker.cs`
|
||||
- `EmailSenderWorker.cs`
|
||||
6. Write tests
|
||||
|
||||
**Important Files to Read**:
|
||||
- `AGENTS.md` - Critical decisions and constraints
|
||||
- `legacy/PROJECT_ANALYSIS.md` - Legacy system analysis
|
||||
- This file - Current status and next steps
|
||||
|
||||
**Questions? Issues?**
|
||||
- Check `AGENTS.md` for design decisions
|
||||
- Check legacy code in `legacy/` folder for reference implementations
|
||||
- All database operations use generic repository pattern (see `Repository.cs`)
|
||||
- All external service interfaces documented in `Application/Common/Interfaces/`
|
||||
|
||||
---
|
||||
|
||||
**End of Status Report**
|
||||
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
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using MediatR;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Interface for publishing commands to a message broker (e.g., RabbitMQ)
|
||||
/// </summary>
|
||||
public interface ICommandPublisher
|
||||
{
|
||||
/// <summary>
|
||||
/// Publishes a command to the message broker for asynchronous processing
|
||||
/// </summary>
|
||||
/// <typeparam name="TCommand">The command type (must implement IBaseRequest - covers both IRequest and IRequest<T>)</typeparam>
|
||||
/// <param name="command">The command to publish</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>Task representing the publish operation</returns>
|
||||
Task PublishAsync<TCommand>(TCommand command, CancellationToken cancellationToken = default)
|
||||
where TCommand : IBaseRequest;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Email service interface for SMTP operations.
|
||||
/// Implementation uses Limilabs Mail.dll for production email sending.
|
||||
/// SMTP configuration is injected via IOptions<EmailAccountDto> in appsettings.json.
|
||||
/// Throws AuthenticationFailedException when SMTP authentication fails.
|
||||
/// </summary>
|
||||
public interface IEmailService
|
||||
{
|
||||
/// <summary>
|
||||
/// Sends an email using the configured SMTP account.
|
||||
/// SMTP credentials are configured in appsettings.json (EmailAccount section).
|
||||
/// </summary>
|
||||
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,10 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// Encryption service interface for password encryption.
|
||||
/// </summary>
|
||||
public interface IEncryptionService
|
||||
{
|
||||
string Encrypt(string plainText);
|
||||
string Decrypt(string cipherText);
|
||||
}
|
||||
@@ -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,26 @@
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
/// <summary>
|
||||
/// PDF processing service interface.
|
||||
/// Operates on streams instead of file paths for flexibility.
|
||||
/// </summary>
|
||||
public interface IPdfProcessingService
|
||||
{
|
||||
/// <summary>
|
||||
/// Validates if the provided stream contains a valid PDF document.
|
||||
/// Throws InvalidPdfException if the stream is not a valid PDF.
|
||||
/// </summary>
|
||||
Task<bool> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Extracts embedded files from PDF stream to the specified output directory.
|
||||
/// Returns a list of paths to extracted files.
|
||||
/// </summary>
|
||||
Task<IEnumerable<string>> ExtractEmbeddedFilesAsync(Stream pdfStream, string outputDirectory, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the page count of the PDF document.
|
||||
/// Throws InvalidPdfException if the stream is not a valid PDF.
|
||||
/// </summary>
|
||||
Task<int> GetPageCountAsync(Stream pdfStream, CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Linq.Expressions;
|
||||
|
||||
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository interface for CRUD operations.
|
||||
/// All operations auto-save changes - NO explicit SaveChangesAsync needed!
|
||||
/// </summary>
|
||||
public interface IRepository<TEntity> where TEntity : class
|
||||
{
|
||||
// CREATE
|
||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||
|
||||
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
||||
|
||||
// READ
|
||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
|
||||
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
|
||||
Task<TEntity?> FindFirstAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
Task<TEntity?> FindSingleAsync(Expression<Func<TEntity, bool>> predicate, 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);
|
||||
|
||||
// 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
|
||||
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);
|
||||
|
||||
// DELETE
|
||||
Task DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
|
||||
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, 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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#if NET
|
||||
using DigitalData.MessagingService.Application.Common.Options;
|
||||
using FluentValidation;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System.Reflection;
|
||||
|
||||
namespace DigitalData.MessagingService.Application;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection configuration for Application layer.
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddApplicationServices(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
var assembly = Assembly.GetExecutingAssembly();
|
||||
|
||||
// Read LuckyPennySoft license key from appsettings.json
|
||||
var licenseKey = configuration.GetValue<string>("LuckyPennySoftLicenseKey")
|
||||
?? throw new InvalidOperationException("LuckyPennySoftLicenseKey not found in configuration");
|
||||
|
||||
// MediatR - Register all handlers
|
||||
services.AddMediatR(config =>
|
||||
{
|
||||
config.LicenseKey = licenseKey;
|
||||
config.RegisterServicesFromAssembly(assembly);
|
||||
});
|
||||
|
||||
// AutoMapper - Use built-in DI extension (AutoMapper 16.2.0+)
|
||||
services.AddAutoMapper(config =>
|
||||
{
|
||||
config.LicenseKey = licenseKey;
|
||||
config.AddMaps(assembly);
|
||||
});
|
||||
|
||||
// FluentValidation - Register all validators
|
||||
services.AddValidatorsFromAssembly(assembly);
|
||||
|
||||
// Register EmailAccounts configuration (IOptions<EmailAccountsOptions>)
|
||||
services.Configure<EmailAccountsOptions>(configuration.GetSection(EmailAccountsOptions.SectionName));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -0,0 +1,23 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
|
||||
<PackageReference Include="MediatR" Version="14.2.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="8.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</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
|
||||
@@ -0,0 +1,18 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="MediatR" Version="12.2.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework)' == 'net462'">
|
||||
<Reference Include="System.ComponentModel.DataAnnotations" />
|
||||
</ItemGroup>
|
||||
|
||||
</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)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Infrastructure.Mappings;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using DigitalData.MessagingService.Infrastructure.Repositories;
|
||||
using DigitalData.MessagingService.Infrastructure.Services;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
using DigitalData.MessagingService.Publisher;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
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 ---
|
||||
// OAuth2 token services — provider-specific implementations registered separately,
|
||||
// dispatcher is the single IOAuth2TokenService consumed by all other services.
|
||||
services.AddSingleton<MicrosoftOAuth2TokenService>();
|
||||
services.AddSingleton<GoogleOAuth2TokenService>();
|
||||
services.AddSingleton<IOAuth2TokenService, OAuth2TokenServiceDispatcher>();
|
||||
|
||||
services.AddHttpClient(nameof(GoogleOAuth2TokenService));
|
||||
services.AddHttpClient(nameof(GoogleOAuth2AuthorizationService));
|
||||
services.AddSingleton<IOAuth2AuthorizationService, GoogleOAuth2AuthorizationService>();
|
||||
|
||||
// Email Service - SMTP outbound (Limilabs Mail.dll) - OAuth2 aware
|
||||
services.AddSingleton<LimilabsEmailService>();
|
||||
services.AddSingleton<IEmailService>(sp => sp.GetRequiredService<LimilabsEmailService>());
|
||||
|
||||
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
||||
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
|
||||
|
||||
// Email Service - POP3 inbound (Limilabs Mail.dll)
|
||||
services.AddScoped<IPop3EmailService, LimilabsPop3EmailService>();
|
||||
|
||||
// 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<SendingEmailConsumerPool>();
|
||||
services.AddMessagingServicePublisher();
|
||||
|
||||
// --- RabbitMQ Configuration ---
|
||||
services.AddRabbitMqConnectionFactory(configuration);
|
||||
|
||||
// --- Data Protection (for encryption) ---
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\ProgramData\EmailService\Keys"))
|
||||
.SetApplicationName("MessagingService");
|
||||
|
||||
// Register Background Workers
|
||||
services.AddHostedService<AsyncInitWorker>();
|
||||
services.AddHostedService<EmailSyncWorker>();
|
||||
|
||||
services.AddSingleton<IEmailSyncService>(p =>
|
||||
{
|
||||
var hostedServices = p.GetRequiredService<IEnumerable<IHostedService>>();
|
||||
var emailSyncWorkers = hostedServices.OfType<EmailSyncWorker>();
|
||||
return emailSyncWorkers.FirstOrDefault() ?? throw new InvalidOperationException("EmailSyncWorker is not registered.");
|
||||
});
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
// --- Database (InMemory) ---
|
||||
services.AddDbContext<MessagingServiceDbContext>(options =>
|
||||
options.UseInMemoryDatabase("MessagingServiceDb"));
|
||||
|
||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||
|
||||
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
|
||||
|
||||
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
||||
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Publisher\DigitalData.MessagingService.Publisher.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.DataProtection" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="8.0.11" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.11">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.0.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="8.0.11" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="System.Text.Encoding.CodePages" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="Mail">
|
||||
<HintPath>M:\Bibliotheken\3rdParty\Limilabs\Mail\Redistributables\net8.0\Mail.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Folder Include="Messaging\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,19 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Mappings;
|
||||
|
||||
/// <summary>
|
||||
/// AutoMapper profile that registers self-mappings (T -> T) for all domain entities.
|
||||
/// This allows AutoMapper to be used uniformly in the generic repository
|
||||
/// regardless of whether TDto is a DTO or the entity type itself.
|
||||
/// </summary>
|
||||
public class EntitySelfMappingProfile : Profile
|
||||
{
|
||||
public EntitySelfMappingProfile()
|
||||
{
|
||||
CreateMap<EmailAccount, EmailAccount>();
|
||||
CreateMap<ReceivedEmail, ReceivedEmail>();
|
||||
CreateMap<EmailAttachment, EmailAttachment>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for MessagingService.
|
||||
/// </summary>
|
||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
||||
{
|
||||
public DbSet<EmailAccount> EmailAccounts => Set<EmailAccount>();
|
||||
public DbSet<ReceivedEmail> ReceivedEmails => Set<ReceivedEmail>();
|
||||
public DbSet<EmailAttachment> EmailAttachments => Set<EmailAttachment>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
{
|
||||
base.OnModelCreating(modelBuilder);
|
||||
|
||||
modelBuilder.Entity<EmailAccount>(entity =>
|
||||
{
|
||||
entity.Property(e => e.Username)
|
||||
.UseCollation("SQL_Latin1_General_CP1_CI_AS");
|
||||
});
|
||||
|
||||
modelBuilder.Entity<ReceivedEmail>(entity =>
|
||||
{
|
||||
entity.HasMany(e => e.Attachments)
|
||||
.WithOne(a => a.Email)
|
||||
.HasForeignKey(a => a.EmailId)
|
||||
.OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
entity.HasOne(e => e.Account)
|
||||
.WithMany()
|
||||
.HasForeignKey(e => e.AccountId)
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// A single RabbitMQ consumer that processes one email message at a time on its own dedicated channel.
|
||||
/// Multiple instances run in parallel via <see cref="SendingEmailConsumerPool"/> (competing consumers pattern).
|
||||
/// Each instance owns exactly one channel — channels are not thread-safe and must not be shared.
|
||||
/// </summary>
|
||||
public sealed class SendingEmailConsumer : IAsyncDisposable
|
||||
{
|
||||
private readonly string _queueName;
|
||||
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
|
||||
private readonly Lazy<Task> _lazyInit;
|
||||
|
||||
private readonly ILogger<SendingEmailConsumer>? _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Transient identifier assigned to this consumer instance at runtime.
|
||||
/// A new value is generated each time the application starts or a new consumer is created.
|
||||
/// Use this to correlate log entries belonging to the same consumer session across competing instances.
|
||||
/// </summary>
|
||||
public Guid RuntimeId { get; } = Guid.NewGuid();
|
||||
|
||||
public SendingEmailConsumer(string queueName, IEmailService emailService, IServiceScopeFactory scopeFactory, RabbitMqConnectionFactory cnnFactory, ILogger<SendingEmailConsumer>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_queueName = queueName;
|
||||
|
||||
_lazyChannel = new(cnnFactory.CreateChannelAsync);
|
||||
_lazyInit = new(async () =>
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
|
||||
await channel.BasicQosAsync(prefetchSize: 0, prefetchCount: 1, global: false);
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
|
||||
consumer.ReceivedAsync += async (sender, args) =>
|
||||
{
|
||||
SendingEmailEvent? oMailEvent = null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
||||
oMailEvent = JsonSerializer.Deserialize<SendingEmailEvent>(json);
|
||||
|
||||
if (oMailEvent is not null)
|
||||
{
|
||||
if (oMailEvent.UseImapAppend)
|
||||
{
|
||||
await using var scope = scopeFactory.CreateAsyncScope();
|
||||
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
|
||||
await imapService.SendAndAppendAsync(oMailEvent.Mail, oMailEvent.SentFolder, args.CancellationToken);
|
||||
}
|
||||
else
|
||||
await emailService.SendEmailAsync(oMailEvent.Mail, args.CancellationToken);
|
||||
|
||||
// Acknowledge message after successful processing
|
||||
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
||||
|
||||
logger?.LogDebug(
|
||||
"Email successfully sent and acknowledged. RuntimeId={RuntimeId}, Queue={QueueName}, DeliveryTag={DeliveryTag}, To={Recipients}, Subject={Subject}, EventId={EventId}",
|
||||
RuntimeId, _queueName, args.DeliveryTag, oMailEvent.Mail.Recipients, oMailEvent.Mail.Subject, oMailEvent.Id);
|
||||
}
|
||||
else
|
||||
{
|
||||
logger?.LogWarning("Failed to deserialize email message: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
||||
await channel.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?.Mail.Recipients, oMailEvent?.Mail.Subject, args.DeliveryTag);
|
||||
|
||||
// TODO: Error Reporting Strategy
|
||||
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
||||
// - Create EmailErrorReport entity { SendingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
|
||||
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
||||
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
||||
//
|
||||
// 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 channel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
|
||||
}
|
||||
};
|
||||
|
||||
// Start consuming messages (event-driven, non-blocking)
|
||||
await channel.BasicConsumeAsync(
|
||||
queue: _queueName,
|
||||
autoAck: false,
|
||||
consumer: consumer,
|
||||
cancellationToken: cnnFactory.CancellationToken);
|
||||
|
||||
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _queueName);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the consumer: opens a channel, sets QoS, and registers the event handler.
|
||||
/// Called by <see cref="SendingEmailConsumerPool.InitAsync"/>.
|
||||
/// </summary>
|
||||
public async Task InitAsync()
|
||||
{
|
||||
if (_lazyInit.IsValueCreated)
|
||||
_logger?.LogWarning("SendingEmailConsumer already initialized. InitAsync() called multiple times.");
|
||||
|
||||
await _lazyInit.Value;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (!_lazyChannel.IsValueCreated)
|
||||
return;
|
||||
|
||||
var channel = await _lazyChannel.Value;
|
||||
if (channel is not null)
|
||||
{
|
||||
await channel.CloseAsync();
|
||||
await channel.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// Manages a pool of <see cref="SendingEmailConsumer"/> instances that compete for messages
|
||||
/// on the same RabbitMQ queue (competing consumers pattern).
|
||||
/// </summary>
|
||||
public sealed class SendingEmailConsumerPool : IAsyncDisposable
|
||||
{
|
||||
private readonly List<SendingEmailConsumer> _consumers;
|
||||
private readonly ILogger<SendingEmailConsumerPool>? _logger;
|
||||
private readonly int _concurrency;
|
||||
|
||||
public SendingEmailConsumerPool(
|
||||
IOptions<RabbitMqConfiguration> config,
|
||||
IEmailService emailService,
|
||||
IServiceScopeFactory scopeFactory,
|
||||
RabbitMqConnectionFactory cnnFactory,
|
||||
ILogger<SendingEmailConsumerPool>? logger = null,
|
||||
ILogger<SendingEmailConsumer>? consumerLogger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_concurrency = config.Value.ConsumerConcurrency;
|
||||
|
||||
_consumers = [.. Enumerable
|
||||
.Range(0, _concurrency)
|
||||
.Select(_ => new SendingEmailConsumer(config.Value.QueueName, emailService, scopeFactory, cnnFactory, consumerLogger))];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts all consumers in parallel. Each consumer opens its own channel and begins listening.
|
||||
/// </summary>
|
||||
public async Task InitAsync()
|
||||
{
|
||||
_logger?.LogInformation("Starting {Count} competing email consumers.", _concurrency);
|
||||
|
||||
await Task.WhenAll(_consumers.Select(c => c.InitAsync()));
|
||||
|
||||
_logger?.LogInformation("All {Count} email consumers started.", _concurrency);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await Task.WhenAll(_consumers.Select(async c => await c.DisposeAsync().AsTask()));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
||||
|
||||
public class ReceivedEmailRepository(MessagingServiceDbContext Context, IMapper Mapper) : Repository<ReceivedEmail>(Context, Mapper), IReceivedEmailRepository
|
||||
{
|
||||
public async Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = DbSet.AsNoTracking();
|
||||
|
||||
// ── Account filter ─────────────────────────────────────────────────────
|
||||
if (accountQuery is not null)
|
||||
query = query.Where(x => x.AccountId == accountQuery.Id);
|
||||
|
||||
// ── Flag filters ───────────────────────────────────────────────────────
|
||||
if (mailSearchFilter.UnseenOnly)
|
||||
query = query.Where(x => !x.IsSeen);
|
||||
|
||||
// ── Text filters ───────────────────────────────────────────────────────
|
||||
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SubjectContains))
|
||||
query = query.Where(x => x.Subject.Contains(mailSearchFilter.SubjectContains));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SenderContains))
|
||||
query = query.Where(x => x.From.Contains(mailSearchFilter.SenderContains));
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mailSearchFilter.BodyContains))
|
||||
query = query.Where(x => x.TextBody.Contains(mailSearchFilter.BodyContains)
|
||||
|| x.HtmlBody.Contains(mailSearchFilter.BodyContains));
|
||||
|
||||
// ── UID filter ─────────────────────────────────────────────────────────
|
||||
if (mailSearchFilter.Uid is { } uid)
|
||||
{
|
||||
if (uid.Absolute.HasValue)
|
||||
query = query.Where(x => x.Uid == uid.Absolute.Value);
|
||||
else
|
||||
{
|
||||
if (uid.Min.HasValue)
|
||||
query = query.Where(x => x.Uid >= uid.Min.Value);
|
||||
if (uid.Max.HasValue)
|
||||
query = query.Where(x => x.Uid <= uid.Max.Value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Date filter ────────────────────────────────────────────────────────
|
||||
if (mailSearchFilter.Date is { } date)
|
||||
{
|
||||
if (date.After.HasValue)
|
||||
query = query.Where(x => x.Date >= date.After.Value);
|
||||
if (date.Before.HasValue)
|
||||
query = query.Where(x => x.Date <= date.Before.Value);
|
||||
}
|
||||
|
||||
// ── Attachments ────────────────────────────────────────────────────────
|
||||
if (mailSearchFilter.WithAttachments)
|
||||
query = query.Include(x => x.Attachments);
|
||||
|
||||
// ── Sort ───────────────────────────────────────────────────────────────
|
||||
query = mailSearchFilter.SortOrder == MailSortOrder.OldestFirst
|
||||
? query.OrderBy(x => x.Date)
|
||||
: query.OrderByDescending(x => x.Date);
|
||||
|
||||
// ── Limit ──────────────────────────────────────────────────────────────
|
||||
if (mailSearchFilter.MaxCount.HasValue)
|
||||
query = query.Take(mailSearchFilter.MaxCount.Value);
|
||||
|
||||
// ── RecipientContains: To/Cc are IEnumerable<string> (nvarchar(max)) ──
|
||||
// EF Core cannot translate collection predicates on these columns to SQL.
|
||||
// Materialization is deferred until after other DB-side filters narrow the set.
|
||||
var results = await query.ToListAsync(cancellationToken);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(mailSearchFilter.RecipientContains))
|
||||
results = [.. results
|
||||
.Where(x => x.To.Any(t => t.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase))
|
||||
|| x.Cc.Any(c => c.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase)))];
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
using System.Linq.Expressions;
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
||||
|
||||
/// <summary>
|
||||
/// Generic repository implementation with AutoMapper-based CRUD operations.
|
||||
/// IMPORTANT: Each operation auto-saves changes - NO explicit SaveChangesAsync needed!
|
||||
/// </summary>
|
||||
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
||||
{
|
||||
protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
|
||||
|
||||
// --- CREATE ---
|
||||
|
||||
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = Mapper.Map<TEntity>(dto);
|
||||
await DbSet.AddAsync(entity, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entity;
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
||||
await DbSet.AddRangeAsync(entities, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities;
|
||||
}
|
||||
|
||||
// --- READ ---
|
||||
|
||||
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DbSet.FindAsync([id], cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DbSet.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<TEntity>> FindAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
int? skip = null,
|
||||
int? take = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var query = DbSet.Where(predicate);
|
||||
|
||||
if (skip.HasValue)
|
||||
query = query.Skip(skip.Value);
|
||||
|
||||
if (take.HasValue)
|
||||
query = query.Take(take.Value);
|
||||
|
||||
return await query.ToListAsync(cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TEntity?> FindFirstAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<TEntity?> FindSingleAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> CountAsync(
|
||||
Expression<Func<TEntity, bool>>? predicate = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return predicate == null
|
||||
? await DbSet.CountAsync(cancellationToken)
|
||||
: await DbSet.CountAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<bool> AnyAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
return await DbSet.AnyAsync(predicate, cancellationToken);
|
||||
}
|
||||
|
||||
// --- UPSERT ---
|
||||
|
||||
/// <summary>
|
||||
/// Upsert: if no record matches the predicate, creates a new entity;
|
||||
/// if one or more match, updates the FIRST match.
|
||||
/// Returns the entity and a flag indicating whether it was created (true) or updated (false).
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<(TEntity Entity, bool Created)> UpsertAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = Mapper.Map<TEntity>(dto);
|
||||
await DbSet.AddAsync(entity, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, true);
|
||||
}
|
||||
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, false);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Upsert (single-safe): if no record matches the predicate, creates a new entity;
|
||||
/// if exactly one matches, updates it. Throws InvalidOperationException if 2+ match.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<(TEntity Entity, bool Created)> UpsertSingleAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||
|
||||
if (entity is null)
|
||||
{
|
||||
entity = Mapper.Map<TEntity>(dto);
|
||||
await DbSet.AddAsync(entity, cancellationToken);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, true);
|
||||
}
|
||||
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return (entity, false);
|
||||
}
|
||||
|
||||
// --- UPDATE ---
|
||||
|
||||
/// <summary>
|
||||
/// Updates a SINGLE entity that matches the predicate.
|
||||
/// Throws NotFoundException if 0 or 2+ records match.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task UpdateSingleAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||
Mapper.Map(dto, entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ALL entities that match the predicate (bulk operation).
|
||||
/// Returns count of updated records.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<int> UpdateAsync<TDto>(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
TDto dto,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
entities.ForEach(entity => Mapper.Map(dto, entity));
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities.Count;
|
||||
}
|
||||
|
||||
// --- DELETE ---
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a SINGLE entity that matches the predicate.
|
||||
/// Throws NotFoundException if 0 or 2+ records match.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task DeleteSingleAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||
DbSet.Remove(entity);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes ALL entities that match the predicate (bulk operation).
|
||||
/// Returns count of deleted records.
|
||||
/// Auto-saves changes.
|
||||
/// </summary>
|
||||
public async Task<int> DeleteAsync(
|
||||
Expression<Func<TEntity, bool>> predicate,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||
DbSet.RemoveRange(entities);
|
||||
await Context.SaveChangesAsync(cancellationToken);
|
||||
return entities.Count;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted background service responsible for initializing the competing email consumer pool.
|
||||
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
||||
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
||||
/// </summary>
|
||||
public class AsyncInitWorker(SendingEmailConsumerPool ConsumerPool) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await ConsumerPool.InitAsync();
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Application.Common.Options;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger, IMemoryCache Cache) : BackgroundService, IEmailSyncService
|
||||
{
|
||||
private static string ForcedSyncDateCacheKey => $"{nameof(EmailSyncWorker)}_TriggerSync";
|
||||
|
||||
private readonly string DefaultFolder = "INBOX";
|
||||
|
||||
/// <summary>
|
||||
/// Signals the current <see cref="Task.Delay"/> to complete immediately,
|
||||
/// causing the sync loop to start the next cycle without waiting.
|
||||
/// A new TCS is created at the start of each delay so repeated triggers work correctly.
|
||||
/// </summary>
|
||||
private volatile TaskCompletionSource<bool> _syncTrigger = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
/// <summary>
|
||||
/// Triggers an immediate sync cycle by completing the current delay early.
|
||||
/// Safe to call from any thread or HTTP request at any time.
|
||||
/// If a sync is already running, the trigger is ignored — the next cycle starts normally.
|
||||
/// </summary>
|
||||
public DateTime ForceTriggerSync()
|
||||
{
|
||||
return Cache.GetOrCreate(ForcedSyncDateCacheKey, e =>
|
||||
{
|
||||
e.SetAbsoluteExpiration(TimeSpan.FromSeconds(Options.Value.ForcedSyncIntervalSeconds));
|
||||
_syncTrigger.TrySetResult(true);
|
||||
return DateTime.UtcNow;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await UpsertSeedEmailAccount(stoppingToken);
|
||||
|
||||
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
|
||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
|
||||
var pop3Service = scope.ServiceProvider.GetRequiredService<IPop3EmailService>();
|
||||
|
||||
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
||||
{
|
||||
await SyncAccountAsync(account, imapService, pop3Service, stoppingToken);
|
||||
}
|
||||
|
||||
// Reset trigger before waiting so any TriggerSync() call during the delay is caught
|
||||
_syncTrigger = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
var delay = Task.Delay(interval, stoppingToken);
|
||||
var triggered = _syncTrigger.Task;
|
||||
await Task.WhenAny(delay, triggered).ConfigureAwait(false);
|
||||
|
||||
// Propagate cancellation if the host is stopping
|
||||
stoppingToken.ThrowIfCancellationRequested();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SyncAccountAsync(
|
||||
EmailAccount account,
|
||||
IImapEmailService imapService,
|
||||
IPop3EmailService pop3Service,
|
||||
CancellationToken stoppingToken)
|
||||
{
|
||||
if (account.IncomingProtocol == IncomingProtocol.None)
|
||||
return;
|
||||
|
||||
Logger.LogDebug(
|
||||
"Email sync started. Account={Username}, Protocol={Protocol}.",
|
||||
account.Username, account.IncomingProtocol);
|
||||
|
||||
try
|
||||
{
|
||||
var result = account.IncomingProtocol switch
|
||||
{
|
||||
IncomingProtocol.Imap or IncomingProtocol.ImapOAuth2
|
||||
=> await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken),
|
||||
|
||||
IncomingProtocol.Pop3 or IncomingProtocol.Pop3OAuth2
|
||||
=> await pop3Service.SyncEmailsAsync(account, stoppingToken),
|
||||
|
||||
_ => throw new NotSupportedException(
|
||||
$"IncomingProtocol '{account.IncomingProtocol}' is not supported by the sync worker.")
|
||||
};
|
||||
|
||||
Logger.LogDebug(
|
||||
"Email sync completed. Account={Username}, Protocol={Protocol}, Processed={Processed}, Failed={Failed}.",
|
||||
account.Username, account.IncomingProtocol, result.ProcessedCount, result.FailedCount);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex,
|
||||
"Email sync failed. Account={Username}, Protocol={Protocol}.",
|
||||
account.Username, account.IncomingProtocol);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task UpsertSeedEmailAccount(CancellationToken stoppingToken)
|
||||
{
|
||||
await using var scope = Provider.CreateAsyncScope();
|
||||
|
||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||
|
||||
foreach (var account in Options.Value.Accounts)
|
||||
await emailAccountRepo.UpsertAsync(a => a.Username == account.Username, account, stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Encryption service using ASP.NET Core Data Protection API.
|
||||
/// Passwords are encrypted at rest in the database.
|
||||
/// </summary>
|
||||
public class DataProtectionEncryptionService(IDataProtectionProvider Provider) : IEncryptionService
|
||||
{
|
||||
private readonly IDataProtector Protector = Provider.CreateProtector("MessagingService.Passwords");
|
||||
|
||||
public string Encrypt(string plainText)
|
||||
{
|
||||
return Protector.Protect(plainText);
|
||||
}
|
||||
|
||||
public string Decrypt(string cipherText)
|
||||
{
|
||||
return Protector.Unprotect(cipherText);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DevExpress.Pdf;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// PDF processing service using DevExpress.Pdf.
|
||||
/// Implements PDF validation and embedded file extraction using streams.
|
||||
/// </summary>
|
||||
public class DevExpressPdfProcessingService : IPdfProcessingService
|
||||
{
|
||||
public Task<bool> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfStream);
|
||||
|
||||
if (!pdfStream.CanRead)
|
||||
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
|
||||
|
||||
if (!pdfStream.CanSeek)
|
||||
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Position != 0)
|
||||
pdfStream.Position = 0;
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
public async Task<IEnumerable<string>> ExtractEmbeddedFilesAsync(
|
||||
Stream pdfStream,
|
||||
string outputDirectory,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfStream);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(outputDirectory);
|
||||
|
||||
if (!pdfStream.CanRead)
|
||||
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
|
||||
|
||||
if (!pdfStream.CanSeek)
|
||||
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Position != 0)
|
||||
pdfStream.Position = 0;
|
||||
|
||||
if (!Directory.Exists(outputDirectory))
|
||||
Directory.CreateDirectory(outputDirectory);
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
var extractedFiles = new List<string>();
|
||||
var attachments = processor.Document.FileAttachments;
|
||||
|
||||
if (attachments == null || !attachments.Any())
|
||||
return extractedFiles;
|
||||
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
var fileName = attachment.FileName ?? $"attachment_{Guid.NewGuid()}.dat";
|
||||
var outputPath = Path.Combine(outputDirectory, fileName);
|
||||
|
||||
var fileData = attachment.Data;
|
||||
if (fileData == null || fileData.Length == 0)
|
||||
continue;
|
||||
|
||||
await File.WriteAllBytesAsync(outputPath, fileData, cancellationToken);
|
||||
extractedFiles.Add(outputPath);
|
||||
}
|
||||
|
||||
return extractedFiles;
|
||||
}
|
||||
|
||||
public Task<int> GetPageCountAsync(Stream pdfStream, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(pdfStream);
|
||||
|
||||
if (!pdfStream.CanRead)
|
||||
throw new ArgumentException("Stream must be readable.", nameof(pdfStream));
|
||||
|
||||
if (!pdfStream.CanSeek)
|
||||
throw new ArgumentException("Stream must be seekable.", nameof(pdfStream));
|
||||
|
||||
if (pdfStream.Position != 0)
|
||||
pdfStream.Position = 0;
|
||||
|
||||
using var processor = new PdfDocumentProcessor();
|
||||
processor.LoadDocument(pdfStream);
|
||||
|
||||
return Task.FromResult(processor.Document.Pages.Count);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
|
||||
public static class CacheExtensions
|
||||
{
|
||||
private readonly static string ImapCacheKeyPrefix = Guid.NewGuid().ToString();
|
||||
|
||||
private static string CreateImapLastSyncDateCacheKey(int accountId, string folder)
|
||||
{
|
||||
return $"{ImapCacheKeyPrefix}_{accountId}_{folder}_LastImapSyncDate";
|
||||
}
|
||||
|
||||
public static DateTime? GetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder)
|
||||
{
|
||||
return cache.Get<DateTime?>(CreateImapLastSyncDateCacheKey(accountId, folder));
|
||||
}
|
||||
|
||||
public static void SetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder, DateTime date)
|
||||
{
|
||||
var key = CreateImapLastSyncDateCacheKey(accountId, folder);
|
||||
cache.Set(key, date);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Limilabs.Client.IMAP;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
|
||||
public static class ImapExtensions
|
||||
{
|
||||
public static async Task CloseSafelyAsync(this Imap imap)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (imap.Connected)
|
||||
await imap.CloseAsync();
|
||||
}
|
||||
catch { /* Ignore disconnect errors */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using Limilabs.Client.SMTP;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
|
||||
public static class SmtpExtensions
|
||||
{
|
||||
public static async Task CloseSafelyAsync(this Smtp smtp)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (smtp.Connected)
|
||||
await smtp.CloseAsync();
|
||||
}
|
||||
catch { /* Ignore disconnect errors */ }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Web;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Implements the Google OAuth2 authorization code flow.
|
||||
/// Generates consent URLs and exchanges authorization codes for refresh tokens.
|
||||
/// This is a one-time setup operation per account — the resulting refresh token
|
||||
/// is stored in <see cref="EmailAccount.OAuth2RefreshToken"/> and reused by
|
||||
/// <see cref="GoogleOAuth2TokenService"/> for all subsequent token acquisitions.
|
||||
/// </summary>
|
||||
public class GoogleOAuth2AuthorizationService(
|
||||
ILogger<GoogleOAuth2AuthorizationService> Logger,
|
||||
IHttpClientFactory HttpClientFactory) : IOAuth2AuthorizationService
|
||||
{
|
||||
private const string AuthEndpoint = "https://accounts.google.com/o/oauth2/v2/auth";
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
private const string Scope = "https://mail.google.com/";
|
||||
|
||||
public string GetAuthorizationUrl(EmailAccount account, string redirectUri)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
var query = HttpUtility.ParseQueryString(string.Empty);
|
||||
query["client_id"] = account.OAuth2ClientId;
|
||||
query["redirect_uri"] = redirectUri;
|
||||
query["response_type"] = "code";
|
||||
query["scope"] = Scope;
|
||||
query["access_type"] = "offline"; // ensures refresh_token is returned
|
||||
query["prompt"] = "consent"; // forces refresh_token even if already authorized
|
||||
query["state"] = account.Id.ToString();
|
||||
|
||||
var url = $"{AuthEndpoint}?{query}";
|
||||
|
||||
Logger.LogDebug("Generated Google OAuth2 authorization URL for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
return url;
|
||||
}
|
||||
|
||||
public async Task<string> ExchangeCodeForRefreshTokenAsync(
|
||||
EmailAccount account,
|
||||
string code,
|
||||
string redirectUri,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2AuthorizationService cannot handle provider '{account.OAuth2Provider}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||
throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
Logger.LogDebug("Exchanging authorization code for refresh token. Account='{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2AuthorizationService));
|
||||
|
||||
var requestBody = new FormUrlEncodedContent([
|
||||
new("client_id", account.OAuth2ClientId),
|
||||
new("client_secret", account.OAuth2ClientSecret),
|
||||
new("code", code),
|
||||
new("redirect_uri", redirectUri),
|
||||
new("grant_type", "authorization_code"),
|
||||
]);
|
||||
|
||||
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"Google token endpoint returned {(int)response.StatusCode} while exchanging authorization code " +
|
||||
$"for account '{account.Username}'. Response: {responseBody}");
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(tokenResponse.RefreshToken))
|
||||
throw new InvalidOperationException(
|
||||
$"Google did not return a refresh_token for account '{account.Username}'. " +
|
||||
"Ensure 'access_type=offline' and 'prompt=consent' are set in the authorization URL, " +
|
||||
"and that the user has not previously authorized this app without revoking access.");
|
||||
|
||||
Logger.LogInformation("Successfully obtained Google refresh token for account '{Username}' (Id: {Id}).",
|
||||
account.Username, account.Id);
|
||||
|
||||
return tokenResponse.RefreshToken;
|
||||
}
|
||||
|
||||
private sealed class GoogleTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("refresh_token")]
|
||||
public string? RefreshToken { get; init; }
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Acquires OAuth2 access tokens for Google accounts (Gmail / Google Workspace)
|
||||
/// using the OAuth2 client credentials flow against <c>https://oauth2.googleapis.com/token</c>.
|
||||
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
|
||||
///
|
||||
/// <para><b>Required Google Cloud configuration:</b></para>
|
||||
/// <list type="bullet">
|
||||
/// <item>Create a project in <see href="https://console.cloud.google.com/"/>.</item>
|
||||
/// <item>Enable the <b>Gmail API</b>.</item>
|
||||
/// <item>Create an <b>OAuth 2.0 Client ID</b> (type: Web application or Desktop).</item>
|
||||
/// <item>Set <c>OAuth2ClientId</c> and <c>OAuth2ClientSecret</c> in configuration.</item>
|
||||
/// <item>Leave <c>OAuth2TenantId</c> empty — Google does not use tenant IDs.</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// Note: Google's OAuth2 for IMAP/SMTP requires user-level access (Delegated),
|
||||
/// not application-level (Client Credentials). A valid <b>refresh token</b> must
|
||||
/// be stored in <c>OAuth2ClientSecret</c> after the initial user authorization flow.
|
||||
/// The token endpoint is used here to exchange the refresh token for an access token.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class GoogleOAuth2TokenService(
|
||||
ILogger<GoogleOAuth2TokenService> Logger,
|
||||
IHttpClientFactory HttpClientFactory) : IOAuth2TokenService
|
||||
{
|
||||
private const string TokenEndpoint = "https://oauth2.googleapis.com/token";
|
||||
|
||||
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
|
||||
|
||||
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Google)
|
||||
throw new InvalidOperationException(
|
||||
$"GoogleOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
|
||||
$"for account '{account.Username}'. Expected '{OAuth2Provider.Google}'.");
|
||||
|
||||
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
{
|
||||
Logger.LogDebug("Returning cached Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2RefreshToken))
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2RefreshToken is not configured for account '{account.Username}' (Id: {account.Id}). " +
|
||||
"Obtain a refresh token via https://developers.google.com/oauthplayground " +
|
||||
"using scope 'https://mail.google.com/' and set it in OAuth2RefreshToken.");
|
||||
|
||||
Logger.LogDebug("Acquiring new Google OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
|
||||
var httpClient = HttpClientFactory.CreateClient(nameof(GoogleOAuth2TokenService));
|
||||
|
||||
var requestBody = new FormUrlEncodedContent([
|
||||
new("client_id", account.OAuth2ClientId),
|
||||
new("client_secret", account.OAuth2ClientSecret),
|
||||
new("grant_type", "refresh_token"),
|
||||
new("refresh_token", account.OAuth2RefreshToken),
|
||||
]);
|
||||
|
||||
var response = await httpClient.PostAsync(TokenEndpoint, requestBody, cancellationToken);
|
||||
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
throw new InvalidOperationException(
|
||||
$"Google token endpoint returned {(int)response.StatusCode} for account '{account.Username}'. Response: {responseBody}");
|
||||
|
||||
var tokenResponse = await response.Content.ReadFromJsonAsync<GoogleTokenResponse>(cancellationToken: cancellationToken)
|
||||
?? throw new InvalidOperationException($"Failed to deserialize Google token response for account '{account.Username}'.");
|
||||
|
||||
var expiry = DateTimeOffset.UtcNow.AddSeconds(tokenResponse.ExpiresIn);
|
||||
_cache[account.Id] = (tokenResponse.AccessToken, expiry);
|
||||
|
||||
return tokenResponse.AccessToken;
|
||||
}
|
||||
|
||||
private sealed class GoogleTokenResponse
|
||||
{
|
||||
[JsonPropertyName("access_token")]
|
||||
public string AccessToken { get; init; } = string.Empty;
|
||||
|
||||
[JsonPropertyName("expires_in")]
|
||||
public int ExpiresIn { get; init; }
|
||||
|
||||
[JsonPropertyName("token_type")]
|
||||
public string TokenType { get; init; } = string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
using System.Text;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using Limilabs.Client.SMTP;
|
||||
using Limilabs.Mail;
|
||||
using Limilabs.Mail.Headers;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
||||
/// Supports both plain/STARTTLS and OAuth2 (XOAUTH2) authentication.
|
||||
/// </summary>
|
||||
public class LimilabsEmailService(IOAuth2TokenService oauth2TokenService) : IEmailService
|
||||
{
|
||||
static LimilabsEmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(EmailContext context, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var smtp = new Smtp();
|
||||
ISendMessageResult? result = null;
|
||||
try
|
||||
{
|
||||
await ConnectAndAuthenticateSmtpAsync(smtp, context.Sender, cancellationToken);
|
||||
|
||||
var builder = new MailBuilder();
|
||||
builder.From.Add(new MailBox(context.Sender.Username));
|
||||
|
||||
foreach (var recipient in context.Recipients)
|
||||
builder.To.Add(new MailBox(recipient));
|
||||
|
||||
builder.Subject = context.Subject;
|
||||
|
||||
if (context.IsHtml)
|
||||
builder.Html = context.Body;
|
||||
else
|
||||
builder.Text = context.Body;
|
||||
|
||||
AddAttachments(builder, context.Attachments);
|
||||
|
||||
var mail = builder.Create();
|
||||
|
||||
result = await smtp.SendMessageAsync(mail, cancellationToken);
|
||||
|
||||
if (result.Status != SendMessageStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}. {ErrorMessageBuilder(result)}");
|
||||
}
|
||||
|
||||
await smtp.CloseAsync(cancellationToken);
|
||||
}
|
||||
catch (Limilabs.Client.ServerException ex)
|
||||
{
|
||||
await smtp.CloseSafelyAsync();
|
||||
throw new AuthenticationFailedException($"SMTP authentication failed. {ErrorMessageBuilder(result)}", ex);
|
||||
}
|
||||
catch (Exception ex) when (ex is not AuthenticationFailedException && ex is not InvalidOperationException)
|
||||
{
|
||||
await smtp.CloseSafelyAsync();
|
||||
throw new InvalidOperationException($"Failed to send email via SMTP server. {ErrorMessageBuilder(result)}", ex);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await smtp.CloseSafelyAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
internal async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp, EmailAccount smtpAccount, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (smtpAccount.SmtpUseSsl)
|
||||
{
|
||||
await smtp.ConnectSSLAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await smtp.ConnectAsync(smtpAccount.SmtpServer, smtpAccount.SmtpPort, cancellationToken);
|
||||
}
|
||||
|
||||
if (smtpAccount.UseOAuth2)
|
||||
{
|
||||
var token = await oauth2TokenService.GetAccessTokenAsync(smtpAccount, cancellationToken);
|
||||
await smtp.LoginOAUTH2Async(smtpAccount.Username, token, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await smtp.LoginAsync(smtpAccount.Username, smtpAccount.Password, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string ErrorMessageBuilder(ISendMessageResult? result = null)
|
||||
{
|
||||
if(result is null || result.GeneralErrors.Count == 0)
|
||||
return string.Empty;
|
||||
else if(result.GeneralErrors.Count == 1)
|
||||
return $"Error: {result.GeneralErrors.FirstOrDefault()}";
|
||||
|
||||
var message = new StringBuilder("Errors:\n");
|
||||
|
||||
foreach (var error in result.GeneralErrors)
|
||||
{
|
||||
message.AppendLine($" • {error}");
|
||||
}
|
||||
|
||||
return message.ToString();
|
||||
}
|
||||
|
||||
internal static void AddAttachments(MailBuilder builder, IEnumerable<EmailAttachmentDto> attachments)
|
||||
{
|
||||
foreach (var attachment in attachments)
|
||||
{
|
||||
if (attachment.IsInline)
|
||||
{
|
||||
var visual = builder.AddVisual(attachment.Content);
|
||||
visual.FileName = attachment.FileName;
|
||||
visual.ContentId = string.IsNullOrWhiteSpace(attachment.ContentId)
|
||||
? attachment.FileName
|
||||
: attachment.ContentId;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
|
||||
visual.ContentType = ContentType.Parse(attachment.ContentType);
|
||||
}
|
||||
else
|
||||
{
|
||||
var part = builder.AddAttachment(attachment.Content);
|
||||
part.FileName = attachment.FileName;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachment.ContentType))
|
||||
part.ContentType = ContentType.Parse(attachment.ContentType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(attachment.ContentId))
|
||||
part.ContentId = attachment.ContentId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||
using Limilabs.Client.IMAP;
|
||||
using Limilabs.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// IMAP email service using Limilabs Mail.dll.
|
||||
/// Opens a fresh connection per call — stateless and thread-safe.
|
||||
/// Supports both password and OAuth2 (XOAUTH2) authentication.
|
||||
/// </summary>
|
||||
public class LimilabsImapEmailService(
|
||||
ILogger<LimilabsImapEmailService> Logger,
|
||||
IRepository<ReceivedEmail> Repository,
|
||||
IOAuth2TokenService oauth2TokenService,
|
||||
LimilabsEmailService smtpService) : IImapEmailService
|
||||
{
|
||||
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||
|
||||
static LimilabsImapEmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
// Public API
|
||||
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||
try
|
||||
{
|
||||
#region Find UIDs
|
||||
// Server-side: only date range; all other filters are applied in-process after cache retrieval
|
||||
List<ICriterion> criterions = [];
|
||||
|
||||
var since = GetLastImapSyncDate(account.Id, folder);
|
||||
|
||||
if (since is not null && since != default)
|
||||
criterions.Add(Expression.SentSince(since.Value));
|
||||
|
||||
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
|
||||
|
||||
var operationStartTime = DateTime.UtcNow;
|
||||
|
||||
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
|
||||
#endregion
|
||||
|
||||
if (uids.Count == 0)
|
||||
return new EmailSyncResult();
|
||||
|
||||
var emails = new List<ReceivedEmailDto>(uids.Count);
|
||||
|
||||
var failedCount = 0;
|
||||
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
|
||||
continue;
|
||||
|
||||
cancel.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
#region Read email
|
||||
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
||||
var mail = new MailBuilder().CreateFromEml(eml);
|
||||
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
||||
|
||||
var attachments = new List<EmailAttachmentDto>();
|
||||
|
||||
foreach (var att in mail.Attachments)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = att.FileName ?? "attachment",
|
||||
Content = att.Data,
|
||||
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = false,
|
||||
ContentId = att.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var vis in mail.Visuals)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = vis.FileName ?? "inline",
|
||||
Content = vis.Data,
|
||||
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = true,
|
||||
ContentId = vis.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
var email = new ReceivedEmailDto
|
||||
{
|
||||
Uid = uid,
|
||||
AccountId = account.Id,
|
||||
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
|
||||
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Subject = mail.Subject ?? string.Empty,
|
||||
TextBody = mail.Text ?? string.Empty,
|
||||
HtmlBody = mail.Html ?? string.Empty,
|
||||
Date = mail.Date ?? DateTime.MinValue,
|
||||
IsSeen = flags.Contains(Flag.Seen),
|
||||
Attachments = attachments,
|
||||
Folder = folder
|
||||
};
|
||||
#endregion Read email
|
||||
|
||||
emails.Add(email);
|
||||
|
||||
SetLastImapSyncDate(account.Id, folder, operationStartTime);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedCount += 1;
|
||||
Logger.LogWarning(ex,
|
||||
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
|
||||
uid, folder);
|
||||
}
|
||||
}
|
||||
|
||||
await imap.CloseAsync(cancel);
|
||||
|
||||
await Repository.CreateRangeAsync(emails, cancel);
|
||||
|
||||
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
|
||||
{
|
||||
using var imap = await OpenAsync(account, folder, cancel: cancel);
|
||||
try
|
||||
{
|
||||
await imap.MarkMessageSeenByUIDAsync(uid, cancel);
|
||||
await imap.CloseAsync(cancel);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
public async Task SendAndAppendAsync(EmailContext context, string sentFolder = "Sent", CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Send via SMTP first
|
||||
await smtpService.SendEmailAsync(context, cancellationToken);
|
||||
|
||||
// Then upload a copy to the IMAP Sent folder (no need to SELECT first)
|
||||
using var imap = await OpenAsync(context.Sender, "INBOX", cancel: cancellationToken);
|
||||
try
|
||||
{
|
||||
var builder = new MailBuilder();
|
||||
builder.From.Add(new Limilabs.Mail.Headers.MailBox(context.Sender.Username));
|
||||
|
||||
foreach (var recipient in context.Recipients)
|
||||
builder.To.Add(new Limilabs.Mail.Headers.MailBox(recipient));
|
||||
|
||||
builder.Subject = context.Subject;
|
||||
|
||||
if (context.IsHtml)
|
||||
builder.Html = context.Body;
|
||||
else
|
||||
builder.Text = context.Body;
|
||||
|
||||
LimilabsEmailService.AddAttachments(builder, context.Attachments);
|
||||
|
||||
var mail = builder.Create();
|
||||
|
||||
var uploadInfo = new Limilabs.Client.IMAP.UploadMessageInfo
|
||||
{
|
||||
Flags = [Limilabs.Client.IMAP.Flag.Seen]
|
||||
};
|
||||
|
||||
await imap.UploadMessageAsync(sentFolder, mail, uploadInfo, cancellationToken);
|
||||
await imap.CloseAsync(cancellationToken);
|
||||
}
|
||||
catch
|
||||
{
|
||||
await imap.CloseSafelyAsync();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", bool createIfMissing = false, CancellationToken cancel = default)
|
||||
{
|
||||
var imap = new Imap();
|
||||
|
||||
if (account.ImapUseSsl)
|
||||
await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||
else
|
||||
await imap.ConnectAsync(account.ImapServer!, account.ImapPort, cancel: cancel);
|
||||
|
||||
if (account.UseOAuth2)
|
||||
{
|
||||
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancel);
|
||||
await imap.LoginOAUTH2Async(account.Username, token, cancel);
|
||||
}
|
||||
else
|
||||
await imap.LoginAsync(account.Username, account.Password, cancel);
|
||||
|
||||
if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase))
|
||||
await imap.SelectInboxAsync(cancel);
|
||||
else
|
||||
{
|
||||
if (createIfMissing)
|
||||
{
|
||||
var folders = await imap.GetFoldersAsync(cancel);
|
||||
if (!folders.Any(f => string.Equals(f.Name, folder, StringComparison.OrdinalIgnoreCase)))
|
||||
await imap.CreateFolderAsync(folder, cancel);
|
||||
}
|
||||
|
||||
await imap.SelectAsync(folder, cancel);
|
||||
}
|
||||
|
||||
return imap;
|
||||
}
|
||||
|
||||
#region IMAP Last Sync Date Cache
|
||||
private readonly ConcurrentDictionary<ImapCacheKey, DateTime> _cache = new();
|
||||
|
||||
private record ImapCacheKey(int AccountId, string Folder);
|
||||
|
||||
public DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX")
|
||||
{
|
||||
return _cache.GetValueOrDefault(new ImapCacheKey(accountId, folder));
|
||||
}
|
||||
|
||||
private void SetLastImapSyncDate(int accountId, string folder, DateTime date)
|
||||
{
|
||||
var key = new ImapCacheKey(accountId, folder);
|
||||
_cache[key] = date;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using Limilabs.Client.POP3;
|
||||
using Limilabs.Mail;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// POP3 email service using Limilabs Mail.dll.
|
||||
/// Opens a fresh connection per call — stateless and thread-safe.
|
||||
/// Supports both password and OAuth2 (XOAUTH2) authentication.
|
||||
/// </summary>
|
||||
public class LimilabsPop3EmailService(
|
||||
ILogger<LimilabsPop3EmailService> Logger,
|
||||
IRepository<ReceivedEmail> Repository,
|
||||
IOAuth2TokenService oauth2TokenService) : IPop3EmailService
|
||||
{
|
||||
private const string Pop3Folder = "INBOX";
|
||||
|
||||
static LimilabsPop3EmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var pop3 = await OpenAsync(account, cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
// POP3 uses string UIDs (UIDL command)
|
||||
var uidMap = await pop3.GetUIDAsync(cancellationToken);
|
||||
|
||||
if (uidMap.Count == 0)
|
||||
{
|
||||
await pop3.CloseAsync(false, cancellationToken);
|
||||
return new EmailSyncResult();
|
||||
}
|
||||
|
||||
var emails = new List<ReceivedEmailDto>(uidMap.Count);
|
||||
var failedCount = 0;
|
||||
|
||||
foreach (var kvp in uidMap)
|
||||
{
|
||||
// kvp.Key = message number (long), kvp.Value = POP3 UID (string)
|
||||
var msgNumber = kvp.Key;
|
||||
var pop3Uid = kvp.Value;
|
||||
|
||||
// Use a stable numeric hash of the string UID for storage (ReceivedEmail.Uid is long)
|
||||
var numericUid = (long)Math.Abs((uint)pop3Uid.GetHashCode());
|
||||
|
||||
if (await Repository.AnyAsync(x => x.Uid == numericUid && x.AccountId == account.Id, cancellationToken))
|
||||
continue;
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
try
|
||||
{
|
||||
var eml = await pop3.GetMessageByNumberAsync(msgNumber, cancellationToken);
|
||||
var mail = new MailBuilder().CreateFromEml(eml);
|
||||
|
||||
var attachments = new List<EmailAttachmentDto>();
|
||||
|
||||
foreach (var att in mail.Attachments)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = att.FileName ?? "attachment",
|
||||
Content = att.Data,
|
||||
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = false,
|
||||
ContentId = att.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
foreach (var vis in mail.Visuals)
|
||||
{
|
||||
attachments.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = vis.FileName ?? "inline",
|
||||
Content = vis.Data,
|
||||
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||
IsInline = true,
|
||||
ContentId = vis.ContentId
|
||||
});
|
||||
}
|
||||
|
||||
var email = new ReceivedEmailDto
|
||||
{
|
||||
Uid = numericUid,
|
||||
AccountId = account.Id,
|
||||
From = mail.From.FirstOrDefault()?.Address ?? string.Empty,
|
||||
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)],
|
||||
Subject = mail.Subject ?? string.Empty,
|
||||
TextBody = mail.Text ?? string.Empty,
|
||||
HtmlBody = mail.Html ?? string.Empty,
|
||||
Date = mail.Date ?? DateTime.MinValue,
|
||||
IsSeen = false, // POP3 has no seen/unseen flags
|
||||
Attachments = attachments,
|
||||
Folder = Pop3Folder
|
||||
};
|
||||
|
||||
emails.Add(email);
|
||||
SetLastPop3SyncDate(account.Id, DateTime.UtcNow);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
failedCount += 1;
|
||||
Logger.LogWarning(ex,
|
||||
"Failed to fetch POP3 message number={Number} for account {Username}. Skipping.",
|
||||
msgNumber, account.Username);
|
||||
}
|
||||
}
|
||||
|
||||
// Close without deleting messages (leaveOnServer = false means do not delete = leave on server)
|
||||
await pop3.CloseAsync(false, cancellationToken);
|
||||
|
||||
await Repository.CreateRangeAsync(emails, cancellationToken);
|
||||
|
||||
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
|
||||
}
|
||||
catch
|
||||
{
|
||||
try { await pop3.CloseAsync(false, cancellationToken); } catch { /* ignore */ }
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<Pop3> OpenAsync(EmailAccount account, CancellationToken cancellationToken)
|
||||
{
|
||||
var pop3 = new Pop3();
|
||||
|
||||
if (account.Pop3UseSsl)
|
||||
await pop3.ConnectSSLAsync(account.Pop3Server!, cancellationToken);
|
||||
else
|
||||
await pop3.ConnectAsync(account.Pop3Server!, cancellationToken);
|
||||
|
||||
if (account.UseOAuth2)
|
||||
{
|
||||
var token = await oauth2TokenService.GetAccessTokenAsync(account, cancellationToken);
|
||||
await pop3.LoginOAUTH2Async(account.Username, token, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
await pop3.LoginAsync(account.Username, account.Password, cancellationToken);
|
||||
}
|
||||
|
||||
return pop3;
|
||||
}
|
||||
|
||||
#region POP3 Last Sync Date Cache
|
||||
private readonly ConcurrentDictionary<int, DateTime> _cache = new();
|
||||
|
||||
public DateTime? GetLastPop3SyncDate(int accountId)
|
||||
=> _cache.GetValueOrDefault(accountId);
|
||||
|
||||
private void SetLastPop3SyncDate(int accountId, DateTime date)
|
||||
=> _cache[accountId] = date;
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Identity.Client;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Acquires OAuth2 access tokens using Microsoft Identity (MSAL) with the client credentials flow.
|
||||
/// Supports Microsoft 365 / Exchange Online accounts (IMAP, POP3, SMTP via XOAUTH2).
|
||||
/// Tokens are cached in-memory and reused until 5 minutes before expiry.
|
||||
///
|
||||
/// <para><b>Required Azure App Registration permissions (Application, not Delegated):</b></para>
|
||||
/// <list type="bullet">
|
||||
/// <item><c>IMAP.AccessAsApp</c> — read mail via IMAP</item>
|
||||
/// <item><c>SMTP.SendAsApp</c> — send mail via SMTP</item>
|
||||
/// <item><c>POP.AccessAsApp</c> — read mail via POP3 (optional)</item>
|
||||
/// </list>
|
||||
///
|
||||
/// <para>
|
||||
/// The single scope <c>https://outlook.office365.com/.default</c> is used intentionally.
|
||||
/// The <c>.default</c> suffix instructs Azure AD to issue a token covering <em>all</em>
|
||||
/// Application permissions that have been pre-consented in the App Registration,
|
||||
/// so there is no need to list individual scopes here.
|
||||
/// </para>
|
||||
///
|
||||
/// <para>
|
||||
/// <c>OAuth2TenantId</c> accepts either a tenant GUID, a domain name
|
||||
/// (e.g. <c>didaloghe</c> or <c>didaloghe.onmicrosoft.com</c>), or <c>"common"</c>.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public class MicrosoftOAuth2TokenService(ILogger<MicrosoftOAuth2TokenService> Logger) : IOAuth2TokenService
|
||||
{
|
||||
/// <summary>
|
||||
/// <c>.default</c> requests all Application permissions pre-consented in Azure Portal.
|
||||
/// This covers IMAP.AccessAsApp, SMTP.SendAsApp and POP.AccessAsApp in one token.
|
||||
/// </summary>
|
||||
private static readonly string[] Scopes = ["https://outlook.office365.com/.default"];
|
||||
|
||||
private readonly ConcurrentDictionary<int, (string Token, DateTimeOffset Expiry)> _cache = new();
|
||||
|
||||
public async Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (account.OAuth2Provider != OAuth2Provider.Microsoft)
|
||||
throw new InvalidOperationException(
|
||||
$"MicrosoftOAuth2TokenService cannot handle provider '{account.OAuth2Provider}' " +
|
||||
$"for account '{account.Username}'. Expected '{OAuth2Provider.Microsoft}'.");
|
||||
|
||||
if (_cache.TryGetValue(account.Id, out var cached) && cached.Expiry > DateTimeOffset.UtcNow.AddMinutes(5))
|
||||
{
|
||||
Logger.LogDebug("Returning cached OAuth2 token for account {Username} (Id: {Id}).", account.Username, account.Id);
|
||||
return cached.Token;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId))
|
||||
throw new InvalidOperationException($"OAuth2ClientId is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
if (string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
|
||||
throw new InvalidOperationException($"OAuth2ClientSecret is not configured for account '{account.Username}' (Id: {account.Id}).");
|
||||
|
||||
var tenantId = string.IsNullOrWhiteSpace(account.OAuth2TenantId) ? "common" : account.OAuth2TenantId;
|
||||
|
||||
// Azure AD accepts: a tenant GUID, the full domain (e.g. "contoso.onmicrosoft.com"
|
||||
// or a verified custom domain), "common", or "organizations".
|
||||
// Short names like "contoso" without a TLD are NOT valid and will cause AADSTS900023.
|
||||
if (!tenantId.Equals("common", StringComparison.OrdinalIgnoreCase) &&
|
||||
!tenantId.Equals("organizations", StringComparison.OrdinalIgnoreCase) &&
|
||||
!Guid.TryParse(tenantId, out _) &&
|
||||
!tenantId.Contains('.'))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"OAuth2TenantId '{tenantId}' for account '{account.Username}' is not a valid Azure AD tenant identifier. " +
|
||||
$"Use the full domain (e.g. '{tenantId}.onmicrosoft.com'), a tenant GUID, or 'common'.");
|
||||
}
|
||||
|
||||
var app = ConfidentialClientApplicationBuilder
|
||||
.Create(account.OAuth2ClientId)
|
||||
.WithClientSecret(account.OAuth2ClientSecret)
|
||||
.WithAuthority($"https://login.microsoftonline.com/{tenantId}")
|
||||
.Build();
|
||||
|
||||
Logger.LogDebug("Acquiring new OAuth2 token for account {Username} (Id: {Id}) from tenant {Tenant}.",
|
||||
account.Username, account.Id, tenantId);
|
||||
|
||||
var result = await app.AcquireTokenForClient(Scopes)
|
||||
.ExecuteAsync(cancellationToken);
|
||||
|
||||
_cache[account.Id] = (result.AccessToken, result.ExpiresOn);
|
||||
|
||||
return result.AccessToken;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Entities;
|
||||
using DigitalData.MessagingService.Domain.Enums;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Routes OAuth2 token requests to the correct provider-specific implementation
|
||||
/// based on <see cref="EmailAccount.OAuth2Provider"/>.
|
||||
/// Registered as the single <see cref="IOAuth2TokenService"/> in DI — all other
|
||||
/// services depend on this dispatcher rather than on a concrete provider directly.
|
||||
/// </summary>
|
||||
public class OAuth2TokenServiceDispatcher(IServiceProvider ServiceProvider) : IOAuth2TokenService
|
||||
{
|
||||
public Task<string> GetAccessTokenAsync(EmailAccount account, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var service = account.OAuth2Provider switch
|
||||
{
|
||||
OAuth2Provider.Microsoft => ServiceProvider.GetRequiredService<MicrosoftOAuth2TokenService>(),
|
||||
OAuth2Provider.Google => (IOAuth2TokenService)ServiceProvider.GetRequiredService<GoogleOAuth2TokenService>(),
|
||||
|
||||
OAuth2Provider.None => throw new InvalidOperationException(
|
||||
$"Account '{account.Username}' (Id: {account.Id}) has OAuth2Provider = None. " +
|
||||
"Set UseOAuth2 = false or configure a valid OAuth2Provider."),
|
||||
|
||||
_ => throw new NotSupportedException(
|
||||
$"OAuth2Provider '{account.OAuth2Provider}' is not supported. " +
|
||||
$"Supported providers: {string.Join(", ", Enum.GetNames<OAuth2Provider>())}")
|
||||
};
|
||||
|
||||
return service.GetAccessTokenAsync(account, cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DigitalData.MessagingService.Publisher;
|
||||
|
||||
public static class DependencyInjection
|
||||
{
|
||||
public static IServiceCollection AddMessagingServicePublisher(this IServiceCollection services, Action<RabbitMqConfiguration>? configure = null)
|
||||
{
|
||||
if(configure is not null)
|
||||
services.AddRabbitMqConnectionFactory(configure);
|
||||
|
||||
// --- Email Queue (RabbitMQ) ---
|
||||
services.AddSingleton<ISendingEmailPublisher, SendingEmailPublisher>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFrameworkIdentifier)' == '.NETFramework'">
|
||||
<PackageReference Include="System.Text.Json" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\core\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.RabbitMQ\DigitalData.MessagingService.RabbitMQ.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,108 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
|
||||
namespace DigitalData.MessagingService.Publisher;
|
||||
|
||||
/// <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 SendingEmailPublisher : ISendingEmailPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly RabbitMqConfiguration _config;
|
||||
private readonly ILogger<SendingEmailPublisher> _logger;
|
||||
private readonly RabbitMqConnectionFactory _cnnFactory;
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
|
||||
public SendingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<SendingEmailPublisher> logger, RabbitMqConnectionFactory cnnFactory)
|
||||
{
|
||||
_config = config.Value;
|
||||
_logger = logger;
|
||||
_cnnFactory = cnnFactory;
|
||||
_lazyChannel = new(InitChannelAsync);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
||||
/// Called lazily on first use via EnsureInitializedAsync.
|
||||
/// </summary>
|
||||
private async Task<IChannel> InitChannelAsync()
|
||||
{
|
||||
var channel = await _cnnFactory.CreateChannelAsync();
|
||||
|
||||
// Topology declaration can use either channel; use publish channel here
|
||||
// Declare Dead Letter Queue (DLQ) exchange
|
||||
await channel.ExchangeDeclareAsync(exchange: _config.DlqExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
// Declare Dead Letter Queue (DLQ)
|
||||
await channel.QueueDeclareAsync(queue: _config.DlqQueueName, durable: true, exclusive: false, autoDelete: false, arguments: null, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
// Bind DLQ to DLQ exchange
|
||||
await channel.QueueBindAsync(queue: _config.DlqQueueName, exchange: _config.DlqExchangeName, routingKey: _config.DlqRoutingKey, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
// Declare main exchange (Direct type for routing)
|
||||
await channel.ExchangeDeclareAsync(exchange: _config.ExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
// 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 channel.QueueDeclareAsync(queue: _config.QueueName, durable: true, exclusive: false, autoDelete: false, arguments: queueArgs, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
// Bind main queue to exchange with routing key
|
||||
await channel.QueueBindAsync(queue: _config.QueueName, exchange: _config.ExchangeName, routingKey: _config.RoutingKey, cancellationToken: _cnnFactory.CancellationToken);
|
||||
|
||||
_logger.LogInformation("RabbitMQ initialized successfully: Queue={QueueName}, DLQ={DlqQueueName}", _config.QueueName, _config.DlqQueueName);
|
||||
|
||||
return channel;
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(SendingEmailEvent sendingEmailEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(sendingEmailEvent);
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true, // Message persistence
|
||||
ContentType = "application/json",
|
||||
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
||||
};
|
||||
|
||||
var channel = await _lazyChannel.Value;
|
||||
|
||||
await channel.BasicPublishAsync(
|
||||
exchange: _config.ExchangeName,
|
||||
routingKey: _config.RoutingKey,
|
||||
mandatory: false,
|
||||
basicProperties: properties,
|
||||
body: body,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var channel = await _lazyChannel.Value;
|
||||
var queueInfo = await channel.QueueDeclarePassiveAsync(_config.QueueName, cancellationToken);
|
||||
return (int)queueInfo.MessageCount;
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (await _lazyChannel.Value is IChannel channel)
|
||||
{
|
||||
await channel.CloseAsync();
|
||||
await channel.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using System;
|
||||
|
||||
namespace DigitalData.MessagingService.RabbitMQ
|
||||
{
|
||||
/// <summary>
|
||||
/// Dependency injection configuration for Infrastructure layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
private static IServiceCollection AddDefaultServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<RabbitMqConnectionFactory>();
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Infrastructure layer services to the DI container
|
||||
/// </summary>
|
||||
public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, Action<RabbitMqConfiguration> configure)
|
||||
{
|
||||
services.AddDefaultServices();
|
||||
|
||||
// --- RabbitMQ Configuration ---
|
||||
services.Configure(configure);
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds Infrastructure layer services to the DI container
|
||||
/// </summary>
|
||||
public static IServiceCollection AddRabbitMqConnectionFactory(this IServiceCollection services, IConfiguration configuration)
|
||||
{
|
||||
services.AddDefaultServices();
|
||||
|
||||
// --- RabbitMQ Configuration ---
|
||||
services.Configure<RabbitMqConfiguration>(
|
||||
configuration.GetSection(RabbitMqConfiguration.SectionName));
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFrameworks>net462;net480;net8.0</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="10.0.10" />
|
||||
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,85 @@
|
||||
namespace DigitalData.MessagingService.RabbitMQ
|
||||
{
|
||||
/// <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;
|
||||
|
||||
/// <summary>
|
||||
/// Name of the main queue where outbound email messages are consumed from.
|
||||
/// </summary>
|
||||
public string QueueName { get; set; } = "messaging-service.email.outbox";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the exchange to which email messages are published.
|
||||
/// Messages are routed from this exchange to <see cref="QueueName"/> via <see cref="RoutingKey"/>.
|
||||
/// </summary>
|
||||
public string ExchangeName { get; set; } = "messaging-service.emails";
|
||||
|
||||
/// <summary>
|
||||
/// Routing key used to bind <see cref="QueueName"/> to <see cref="ExchangeName"/>.
|
||||
/// </summary>
|
||||
public string RoutingKey { get; set; } = "email.outbox";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the Dead Letter Queue (DLQ) where messages that could not be processed are routed.
|
||||
/// </summary>
|
||||
public string DlqQueueName { get; set; } = "messaging-service.email.outbox.dlq";
|
||||
|
||||
/// <summary>
|
||||
/// Name of the Dead Letter Exchange (DLX) that routes rejected or expired messages to <see cref="DlqQueueName"/>.
|
||||
/// </summary>
|
||||
public string DlqExchangeName { get; set; } = "messaging-service.emails.dlq";
|
||||
|
||||
/// <summary>
|
||||
/// Routing key used to bind <see cref="DlqQueueName"/> to <see cref="DlqExchangeName"/>.
|
||||
/// </summary>
|
||||
public string DlqRoutingKey { get; set; } = "email.outbox.dlq";
|
||||
|
||||
/// <summary>
|
||||
/// Maximum number of email messages processed concurrently by the consumer.
|
||||
/// Maps directly to RabbitMQ prefetchCount. Recommended: 3–5.
|
||||
/// </summary>
|
||||
public ushort ConsumerConcurrency { get; set; } = 5;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DigitalData.MessagingService.RabbitMQ
|
||||
{
|
||||
public sealed class RabbitMqConnectionFactory : IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _consumerCts = new CancellationTokenSource();
|
||||
|
||||
private readonly RabbitMqConfiguration _config;
|
||||
|
||||
private readonly ILogger<RabbitMqConnectionFactory>
|
||||
#if nullable
|
||||
?
|
||||
#endif
|
||||
_logger;
|
||||
|
||||
private readonly Lazy<Task<IConnection>> _lazyConnectionProvider;
|
||||
|
||||
public CancellationToken CancellationToken => _consumerCts.Token;
|
||||
|
||||
public Task<IConnection> GetDefaultConnectionAsync()
|
||||
{
|
||||
return _lazyConnectionProvider.Value;
|
||||
}
|
||||
|
||||
public async Task<IChannel> CreateChannelAsync()
|
||||
{
|
||||
var cnn = await GetDefaultConnectionAsync();
|
||||
return await cnn.CreateChannelAsync(cancellationToken: CancellationToken);
|
||||
}
|
||||
|
||||
public async Task<AsyncEventingBasicConsumer> CreateConsumerAsync()
|
||||
{
|
||||
var channel = await CreateChannelAsync();
|
||||
return new AsyncEventingBasicConsumer(channel);
|
||||
}
|
||||
|
||||
public RabbitMqConnectionFactory(IOptions<RabbitMqConfiguration> config)
|
||||
{
|
||||
_config = config.Value;
|
||||
_lazyConnectionProvider = new Lazy<Task<IConnection>>(async () =>
|
||||
{
|
||||
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),
|
||||
};
|
||||
|
||||
return await factory.CreateConnectionAsync(CancellationToken);
|
||||
});
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
#if NET
|
||||
await _consumerCts.CancelAsync();
|
||||
#else
|
||||
_consumerCts.Cancel();
|
||||
#endif
|
||||
|
||||
var connection = await _lazyConnectionProvider.Value;
|
||||
if (connection != null)
|
||||
{
|
||||
await connection.CloseAsync();
|
||||
await connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
[Route("api/[controller]")]
|
||||
[ApiController]
|
||||
public class EmailAccountController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
/// <param name="query"></param>
|
||||
/// <param name="cancellationToken"></param>
|
||||
/// <returns></returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> GetEmailAccount([FromQuery] GetEmailAccountQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
return Ok(await mediator.Send(query, cancellationToken));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
using DigitalData.MessagingService.Application.EmailSending.Commands;
|
||||
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using DigitalData.MessagingService.Application.Common.Dto;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Email sending API controller.
|
||||
/// Enqueues outgoing emails to RabbitMQ for async processing.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class EmailController(IMediator mediator) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
public enum OnlyFilter
|
||||
{
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
HtmlBody,
|
||||
/// <summary>
|
||||
///
|
||||
/// </summary>
|
||||
Uid,
|
||||
}
|
||||
|
||||
#region Send
|
||||
/// <summary>
|
||||
/// Send an email, optionally with file attachments.
|
||||
/// Omit the <c>attachments</c> field for a plain send.
|
||||
/// </summary>
|
||||
/// <param name="command">Email fields as form values</param>
|
||||
/// <param name="attachments">Optional uploaded files</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||
[HttpPost]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendEmail(
|
||||
[FromForm] PublishEmailCommand command,
|
||||
IFormFileCollection? attachments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandWithAttachments = command.WithAttachments(
|
||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||
|
||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||
return Accepted(new { Id = eventId });
|
||||
}
|
||||
|
||||
private static async Task<IEnumerable<EmailAttachmentDto>> BuildAttachmentsAsync(
|
||||
IFormFileCollection? files,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (files is null || files.Count == 0)
|
||||
return [];
|
||||
|
||||
var result = new List<EmailAttachmentDto>(files.Count);
|
||||
|
||||
foreach (var file in files)
|
||||
{
|
||||
using var ms = new MemoryStream();
|
||||
await file.CopyToAsync(ms, cancellationToken);
|
||||
|
||||
result.Add(new EmailAttachmentDto
|
||||
{
|
||||
FileName = file.FileName,
|
||||
Content = ms.ToArray(),
|
||||
ContentType = file.ContentType
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
#endregion Send
|
||||
|
||||
#region Receive
|
||||
/// <summary>
|
||||
/// Fetch emails from an IMAP mailbox.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters for filtering and fetching emails from the IMAP mailbox.</param>
|
||||
/// <param name="only"></param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||
[HttpGet]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var res = await mediator.Send(query, cancellationToken);
|
||||
|
||||
if(!res.Emails.Any())
|
||||
return NotFound("No emails found matching the specified criteria.");
|
||||
|
||||
if (only == OnlyFilter.HtmlBody)
|
||||
{
|
||||
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
return Content(htmlBody, "text/html");
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
else if (only == OnlyFilter.Uid)
|
||||
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||
else
|
||||
return Ok(res);
|
||||
}
|
||||
#endregion Receive
|
||||
|
||||
#region IMAP Send
|
||||
/// <summary>
|
||||
/// Send an email using IMAP account credentials (via RabbitMQ queue).
|
||||
/// After the message is sent, it is appended to the IMAP Sent Items folder.
|
||||
/// </summary>
|
||||
/// <param name="command">Email fields as form values</param>
|
||||
/// <param name="attachments">Optional uploaded files</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||
[HttpPost("imap/send")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendEmailViaImap(
|
||||
[FromForm] PublishEmailViaImapCommand command,
|
||||
IFormFileCollection? attachments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandWithAttachments = command.WithAttachments(
|
||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||
|
||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||
return Accepted(new { Id = eventId });
|
||||
}
|
||||
#endregion IMAP Send
|
||||
|
||||
#region POP3 Receive
|
||||
/// <summary>
|
||||
/// Fetch emails from a POP3 mailbox.
|
||||
/// Triggers an on-demand POP3 sync before returning results.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters for filtering and fetching emails from the POP3 mailbox.</param>
|
||||
/// <param name="only">Optional filter to return only specific fields.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||
[HttpGet("pop3")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> FetchEmailsViaPop3(
|
||||
[FromQuery] ReadEmailViaPop3Query query,
|
||||
[FromQuery] OnlyFilter? only = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var res = await mediator.Send(query, cancellationToken);
|
||||
|
||||
if (!res.Emails.Any())
|
||||
return NotFound("No emails found matching the specified criteria.");
|
||||
|
||||
if (only == OnlyFilter.HtmlBody)
|
||||
{
|
||||
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
return Content(htmlBody, "text/html");
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
else if (only == OnlyFilter.Uid)
|
||||
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||
else
|
||||
return Ok(res);
|
||||
}
|
||||
#endregion POP3 Receive
|
||||
|
||||
#region OAuth2 Send
|
||||
/// <summary>
|
||||
/// Send an email via SMTP using OAuth2 authentication (via RabbitMQ queue).
|
||||
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||
/// </summary>
|
||||
/// <param name="command">Email fields as form values</param>
|
||||
/// <param name="attachments">Optional uploaded files</param>
|
||||
/// <param name="cancellationToken">Cancellation token</param>
|
||||
/// <returns>HTTP 202 Accepted with the queued event ID</returns>
|
||||
[HttpPost("oauth2/send")]
|
||||
[Consumes("multipart/form-data")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> SendEmailViaOAuth2(
|
||||
[FromForm] PublishEmailViaOAuth2Command command,
|
||||
IFormFileCollection? attachments,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var commandWithAttachments = command.WithAttachments(
|
||||
await BuildAttachmentsAsync(attachments, cancellationToken));
|
||||
|
||||
var eventId = await mediator.Send(commandWithAttachments, cancellationToken);
|
||||
return Accepted(new { Id = eventId });
|
||||
}
|
||||
#endregion OAuth2 Send
|
||||
|
||||
#region OAuth2 Receive
|
||||
/// <summary>
|
||||
/// Fetch emails from an IMAP mailbox using OAuth2 authentication.
|
||||
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
|
||||
/// </summary>
|
||||
/// <param name="query">Query parameters for filtering and fetching emails.</param>
|
||||
/// <param name="only">Optional filter to return only specific fields.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 with list of received emails.</returns>
|
||||
[HttpGet("oauth2/imap")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> FetchEmailsViaOAuth2(
|
||||
[FromQuery] ReadEmailViaOAuth2Query query,
|
||||
[FromQuery] OnlyFilter? only = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var res = await mediator.Send(query, cancellationToken);
|
||||
|
||||
if (!res.Emails.Any())
|
||||
return NotFound("No emails found matching the specified criteria.");
|
||||
|
||||
if (only == OnlyFilter.HtmlBody)
|
||||
{
|
||||
if (res.Emails.FirstOrDefault()?.HtmlBody is string htmlBody)
|
||||
return Content(htmlBody, "text/html");
|
||||
else
|
||||
return NotFound();
|
||||
}
|
||||
else if (only == OnlyFilter.Uid)
|
||||
return Ok(res.Emails.Select(e => e.Uid).ToList());
|
||||
else
|
||||
return Ok(res);
|
||||
}
|
||||
#endregion OAuth2 Receive
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
using DigitalData.MessagingService.Application.OAuth2.Commands;
|
||||
using DigitalData.MessagingService.Application.OAuth2.Queries;
|
||||
using MediatR;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Manages the OAuth2 authorization code flow for email accounts.
|
||||
/// Use these endpoints to authorize Google accounts without manually
|
||||
/// obtaining refresh tokens via external tools.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class OAuth2Controller(IMediator mediator, IHttpContextAccessor httpContextAccessor) : ControllerBase
|
||||
{
|
||||
#region Google Authorization Flow
|
||||
|
||||
/// <summary>
|
||||
/// Step 1: Redirects the user to Google's consent screen for the specified email account.
|
||||
/// After consent, Google redirects to <c>/api/oauth2/google/callback</c> with an authorization code.
|
||||
/// </summary>
|
||||
/// <param name="username">the email account to authorize.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 302 redirect to Google consent screen.</returns>
|
||||
[HttpGet("google/authorize/{username}")]
|
||||
[ProducesResponseType(StatusCodes.Status302Found)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
public async Task<IActionResult> AuthorizeGoogle([FromRoute] string username, CancellationToken cancellationToken)
|
||||
{
|
||||
var redirectUri = BuildCallbackUri();
|
||||
|
||||
var authUrl = await mediator.Send(new GetOAuth2AuthorizationUrlQuery
|
||||
{
|
||||
Username = username,
|
||||
RedirectUri = redirectUri
|
||||
}, cancellationToken);
|
||||
|
||||
return Redirect(authUrl);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Step 2: Google callback endpoint. Exchanges the authorization code for a refresh token
|
||||
/// and saves it to the email account. This endpoint is called automatically by Google
|
||||
/// after the user grants consent — do not call it directly.
|
||||
/// </summary>
|
||||
/// <param name="code">Authorization code provided by Google.</param>
|
||||
/// <param name="state">Account ID passed as state in the authorization request.</param>
|
||||
/// <param name="error">Error message if the user denied access.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>HTTP 200 on success, HTTP 400 if access was denied.</returns>
|
||||
[HttpGet("google/callback")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||
public async Task<IActionResult> GoogleCallback(
|
||||
[FromQuery] string? code,
|
||||
[FromQuery] string? state,
|
||||
[FromQuery] string? error,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(error))
|
||||
return BadRequest(new { Error = error, Message = "User denied access or an error occurred during Google OAuth2 authorization." });
|
||||
|
||||
if (string.IsNullOrWhiteSpace(code))
|
||||
return BadRequest(new { Error = "missing_code", Message = "Authorization code not received from Google." });
|
||||
|
||||
if (!int.TryParse(state, out var accountId))
|
||||
return BadRequest(new { Error = "invalid_state", Message = "Invalid state parameter — could not determine account ID." });
|
||||
|
||||
var redirectUri = BuildCallbackUri();
|
||||
|
||||
var result = await mediator.Send(new CompleteOAuth2AuthorizationCommand
|
||||
{
|
||||
AccountId = accountId,
|
||||
Code = code,
|
||||
RedirectUri = redirectUri
|
||||
}, cancellationToken);
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
result.Success,
|
||||
result.Username,
|
||||
Message = $"Google OAuth2 authorization completed. Refresh token saved for account '{result.Username}'."
|
||||
});
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private string BuildCallbackUri()
|
||||
{
|
||||
var request = httpContextAccessor.HttpContext!.Request;
|
||||
return $"{request.Scheme}://{request.Host}/api/oauth2/google/callback";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace DigitalData.MessagingService.API.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Controls email synchronization operations.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("api/[controller]")]
|
||||
public class SyncController(IEmailSyncService emailSyncService) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Triggers an immediate email sync cycle for all configured accounts,
|
||||
/// skipping the remaining interval wait.
|
||||
/// If a sync is already in progress, the next cycle will start immediately after it completes.
|
||||
/// </summary>
|
||||
/// <returns>HTTP 202 Accepted.</returns>
|
||||
[HttpPost("trigger")]
|
||||
[ProducesResponseType(StatusCodes.Status202Accepted)]
|
||||
[ProducesResponseType(StatusCodes.Status503ServiceUnavailable)]
|
||||
public IActionResult TriggerSync()
|
||||
{
|
||||
var syncTime = emailSyncService.ForceTriggerSync();
|
||||
return Accepted(new { syncTime });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user