diff --git a/DigitalData.EmailProfiler.sln b/DigitalData.EmailProfiler.sln
index 0fbc1bc..e198862 100644
--- a/DigitalData.EmailProfiler.sln
+++ b/DigitalData.EmailProfiler.sln
@@ -19,7 +19,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{4F20FEFD
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{8EC462FD-D22E-90A8-E5CE-7E832BA40C5D}"
ProjectSection(SolutionItems) = preProject
- AGENTS.md = AGENTS.md
+ agents.md = agents.md
IMPLEMENTATION_GUIDE.md = IMPLEMENTATION_GUIDE.md
README.md = README.md
STATUS.md = STATUS.md
diff --git a/src/DigitalData.EmailProfiler.API/Controllers/EmailsController.cs b/src/DigitalData.EmailProfiler.API/Controllers/EmailsController.cs
new file mode 100644
index 0000000..211f2ca
--- /dev/null
+++ b/src/DigitalData.EmailProfiler.API/Controllers/EmailsController.cs
@@ -0,0 +1,37 @@
+using DigitalData.EmailProfiler.Application.EmailSending.Commands;
+using MediatR;
+using Microsoft.AspNetCore.Mvc;
+
+namespace DigitalData.EmailProfiler.API.Controllers;
+
+///
+/// Email sending API controller
+/// Enqueues outgoing emails to RabbitMQ for async processing
+///
+[ApiController]
+[Route("api/[controller]")]
+public class EmailsController(IMediator mediator) : ControllerBase
+{
+ ///
+ /// Send email (enqueue to RabbitMQ for background processing)
+ ///
+ /// Send email command
+ /// Cancellation token
+ /// HTTP 202 Accepted (queued for processing)
+ [HttpPost("send")]
+ [ProducesResponseType(StatusCodes.Status202Accepted)]
+ [ProducesResponseType(StatusCodes.Status400BadRequest)]
+ public async Task SendEmail([FromBody] SendEmailCommand command, CancellationToken cancellationToken)
+ {
+ var emailOutboxId = await mediator.Send(command, cancellationToken);
+
+ return Accepted(new
+ {
+ Message = "Email queued for sending",
+ EmailOutboxId = emailOutboxId,
+ To = command.Recipient,
+ command.Subject,
+ QueuedAt = DateTime.Now
+ });
+ }
+}
diff --git a/src/DigitalData.EmailProfiler.API/DigitalData.EmailProfiler.API.csproj b/src/DigitalData.EmailProfiler.API/DigitalData.EmailProfiler.API.csproj
index 1131edd..39d943a 100644
--- a/src/DigitalData.EmailProfiler.API/DigitalData.EmailProfiler.API.csproj
+++ b/src/DigitalData.EmailProfiler.API/DigitalData.EmailProfiler.API.csproj
@@ -8,6 +8,9 @@
+
+
+
diff --git a/src/DigitalData.EmailProfiler.API/Middleware/ExceptionHandlingMiddleware.cs b/src/DigitalData.EmailProfiler.API/Middleware/ExceptionHandlingMiddleware.cs
new file mode 100644
index 0000000..11919c1
--- /dev/null
+++ b/src/DigitalData.EmailProfiler.API/Middleware/ExceptionHandlingMiddleware.cs
@@ -0,0 +1,100 @@
+using System.Net;
+using System.Text.Json;
+using DigitalData.EmailProfiler.Domain.Exceptions;
+
+namespace DigitalData.EmailProfiler.API.Middleware;
+
+///
+/// Global exception handling middleware
+///
+public class ExceptionHandlingMiddleware
+{
+ private static readonly JsonSerializerOptions _jsonSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase
+ };
+
+ private readonly RequestDelegate _next;
+ private readonly ILogger _logger;
+
+ public ExceptionHandlingMiddleware(
+ RequestDelegate next,
+ ILogger logger)
+ {
+ _next = next;
+ _logger = logger;
+ }
+
+ public async Task InvokeAsync(HttpContext context)
+ {
+ try
+ {
+ await _next(context);
+ }
+ catch (Exception ex)
+ {
+ await HandleExceptionAsync(context, ex);
+ }
+ }
+
+ private async Task HandleExceptionAsync(HttpContext context, Exception exception)
+ {
+ context.Response.ContentType = "application/json";
+
+ var (statusCode, message) = exception switch
+ {
+ NotFoundException notFoundEx =>
+ (HttpStatusCode.NotFound, notFoundEx.Message),
+
+ AuthenticationFailedException authEx =>
+ (HttpStatusCode.Unauthorized, authEx.Message),
+
+ DmsNotAvailableException dmsEx =>
+ (HttpStatusCode.ServiceUnavailable, dmsEx.Message),
+
+ InvalidPdfException pdfEx =>
+ (HttpStatusCode.BadRequest, pdfEx.Message),
+
+ FluentValidation.ValidationException validationEx =>
+ (HttpStatusCode.BadRequest, FormatValidationErrors(validationEx)),
+
+ _ => (HttpStatusCode.InternalServerError, "An internal server error occurred")
+ };
+
+ context.Response.StatusCode = (int)statusCode;
+
+ // Log the exception
+ if (statusCode == HttpStatusCode.InternalServerError)
+ {
+ _logger.LogError(exception, "Unhandled exception: {Message}", exception.Message);
+ }
+ else
+ {
+ _logger.LogWarning(exception, "Exception handled: {StatusCode} - {Message}",
+ statusCode, message);
+ }
+
+ var response = new
+ {
+ StatusCode = (int)statusCode,
+ Message = message,
+ DetailedMessage = statusCode == HttpStatusCode.InternalServerError
+ ? exception.Message
+ : null,
+ Timestamp = DateTime.Now
+ };
+
+ var json = JsonSerializer.Serialize(response, _jsonSerializerOptions);
+
+ await context.Response.WriteAsync(json);
+ }
+
+ private static string FormatValidationErrors(FluentValidation.ValidationException exception)
+ {
+ var errors = exception.Errors
+ .Select(e => $"{e.PropertyName}: {e.ErrorMessage}")
+ .ToList();
+
+ return string.Join("; ", errors);
+ }
+}
diff --git a/src/DigitalData.EmailProfiler.API/Program.cs b/src/DigitalData.EmailProfiler.API/Program.cs
index 40b8d56..ba3555a 100644
--- a/src/DigitalData.EmailProfiler.API/Program.cs
+++ b/src/DigitalData.EmailProfiler.API/Program.cs
@@ -1,38 +1,97 @@
using DigitalData.EmailProfiler.API;
+using DigitalData.EmailProfiler.API.Middleware;
+using DigitalData.EmailProfiler.API.Workers;
using DigitalData.EmailProfiler.Application;
+using DigitalData.EmailProfiler.Application.Common.Dtos;
using DigitalData.EmailProfiler.Infrastructure;
+using Serilog;
-var builder = WebApplication.CreateBuilder(args);
+// Configure Serilog early
+Log.Logger = new LoggerConfiguration()
+ .WriteTo.Console()
+ .WriteTo.File(
+ path: "logs/emailprofiler-.log",
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 30,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}")
+ .CreateBootstrapLogger();
-// Add appsettings.Secrets.json for sensitive configuration (not committed to git)
-builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
-
-// Register Application layer (MediatR, AutoMapper, FluentValidation)
-builder.Services.AddApplicationServices();
-
-// Register Infrastructure layer (RabbitMQ, Repositories, etc.)
-builder.Services.AddInfrastructure(builder.Configuration);
-
-builder.Services.AddHostedService();
-
-builder.Services.AddControllers();
-
-builder.Services.AddEndpointsApiExplorer();
-builder.Services.AddSwaggerGen();
-
-var app = builder.Build();
-
-// Configure the HTTP request pipeline.
-if (app.Environment.IsDevelopment())
+try
{
- app.UseSwagger();
- app.UseSwaggerUI();
+ Log.Information("Starting EmailProfiler API");
+
+ var builder = WebApplication.CreateBuilder(args);
+
+ // Use Serilog for logging
+ builder.Host.UseSerilog((context, services, configuration) => configuration
+ .ReadFrom.Configuration(context.Configuration)
+ .ReadFrom.Services(services)
+ .Enrich.FromLogContext()
+ .WriteTo.Console()
+ .WriteTo.File(
+ path: "logs/emailprofiler-.log",
+ rollingInterval: RollingInterval.Day,
+ retainedFileCountLimit: 30,
+ outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"));
+
+ // Add appsettings.Secrets.json for sensitive configuration (not committed to git)
+ builder.Configuration.AddJsonFile("appsettings.Secrets.json", optional: true, reloadOnChange: true);
+
+ // Register Application layer (MediatR, AutoMapper, FluentValidation)
+ builder.Services.AddApplicationServices();
+
+ // Register Infrastructure layer (RabbitMQ, Repositories, etc.)
+ builder.Services.AddInfrastructure(builder.Configuration);
+
+ // Register EmailSenderWorker configuration
+ builder.Services.Configure(
+ builder.Configuration.GetSection(EmailSenderWorkerConfiguration.SectionName));
+
+ // Register EmailAccount configuration (IOptions)
+ builder.Services.Configure(
+ builder.Configuration.GetSection("EmailAccount"));
+
+ // Register Background Workers
+ builder.Services.AddHostedService();
+ builder.Services.AddHostedService();
+
+ builder.Services.AddControllers();
+
+ builder.Services.AddEndpointsApiExplorer();
+ builder.Services.AddSwaggerGen();
+
+ var app = builder.Build();
+
+ // Add global exception handling middleware
+ app.UseMiddleware();
+
+ // Add Serilog request logging
+ app.UseSerilogRequestLogging();
+
+ // Configure the HTTP request pipeline.
+ if (app.Environment.IsDevelopment())
+ {
+ app.UseSwagger();
+ app.UseSwaggerUI();
+ }
+
+ app.UseHttpsRedirection();
+
+ app.UseAuthorization();
+
+ app.MapControllers();
+
+ Log.Information("EmailProfiler API started successfully");
+
+ app.Run();
+}
+catch (Exception ex)
+{
+ Log.Fatal(ex, "EmailProfiler API failed to start");
+ throw;
+}
+finally
+{
+ Log.CloseAndFlush();
}
-app.UseHttpsRedirection();
-
-app.UseAuthorization();
-
-app.MapControllers();
-
-app.Run();
diff --git a/src/DigitalData.EmailProfiler.API/appsettings.json b/src/DigitalData.EmailProfiler.API/appsettings.json
index 10f68b8..c42a5e2 100644
--- a/src/DigitalData.EmailProfiler.API/appsettings.json
+++ b/src/DigitalData.EmailProfiler.API/appsettings.json
@@ -1,9 +1,28 @@
{
- "Logging": {
- "LogLevel": {
+ "Serilog": {
+ "MinimumLevel": {
"Default": "Information",
- "Microsoft.AspNetCore": "Warning"
+ "Override": {
+ "Microsoft": "Warning",
+ "Microsoft.AspNetCore": "Warning",
+ "Microsoft.EntityFrameworkCore": "Warning",
+ "System": "Warning"
+ }
}
},
- "AllowedHosts": "*"
-}
+ "AllowedHosts": "*",
+ "Workers": {
+ "EmailSender": {
+ "Enabled": true,
+ "MaxRetryCount": 3
+ }
+ },
+ "EmailAccount": {
+ "Username": "test-flow@digitaldata.works",
+ "SmtpServer": "kundencenter.triplew.de",
+ "SmtpPort": 465,
+ "SmtpUseSsl": true,
+ "UseOAuth2": false
+ },
+ "LuckyPennySoftLicenseKey": "eyJhbGciOiJSUzI1NiIsImtpZCI6Ikx1Y2t5UGVubnlTb2Z0d2FyZUxpY2Vuc2VLZXkvYmJiMTNhY2I1OTkwNGQ4OWI0Y2IxYzg1ZjA4OGNjZjkiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL2x1Y2t5cGVubnlzb2Z0d2FyZS5jb20iLCJhdWQiOiJMdWNreVBlbm55U29mdHdhcmUiLCJleHAiOiIxODE2MTI4MDAwIiwiaWF0IjoiMTc4NDYyNDU1NyIsImFjY291bnRfaWQiOiIwMTk4M2M1OWU0YjM3MjhlYmZkMzEwM2MyYTQ4NmU4NSIsImN1c3RvbWVyX2lkIjoiMDE5ODNjNTllNGIzNzI4ZWJmZDMxMDNjMmE0ODZlODUiLCJzdWJfaWQiOiItIiwiZWRpdGlvbiI6IjAiLCJ0eXBlIjoiMiJ9.IUUO926m9crYGYxMjjKD_n9BnUm-EDyjFIn0YmMUCo7C-QTwvB8WhXP8veTSFsBq-leIIDJ4jyl7Pgc_7ciwg1XhUSIs4mkQroEUaSFCGOxw7Pi41WM8MK5YFSaqLTYYXec9zxgiJbGzABbh3CHTSup3okGnVm_CMoPEs91l2c0A6N1JyZy74urd_tF0KGVKf0MOvzdlQIWLQ8o73S4pTv2N-F6UlzI0fdMtTHMLNNQyr0NdWdnuBk_jMBXO-gy5RE_oCRfMTTYRX2n3XLK6pTfXE0Ct338o9F5sH8Ph2lTXSu56cpdsfZOQZGqCH0LoFp1Dd7RJgIgNmBiTGfvDnA"
+}
\ No newline at end of file
diff --git a/src/DigitalData.EmailProfiler.Domain/Common/DomainConstants.cs b/src/DigitalData.EmailProfiler.Domain/Common/DomainConstants.cs
new file mode 100644
index 0000000..db55631
--- /dev/null
+++ b/src/DigitalData.EmailProfiler.Domain/Common/DomainConstants.cs
@@ -0,0 +1,19 @@
+namespace DigitalData.EmailProfiler.Domain.Common;
+
+///
+/// Domain-wide constants
+///
+public static class DomainConstants
+{
+ ///
+ /// Email processing constants
+ ///
+ public static class Email
+ {
+ ///
+ /// Maximum number of retry attempts for failed email sending
+ /// After this limit, email will be moved to Dead Letter Queue (DLQ)
+ ///
+ public const int MaxRetryCount = 3;
+ }
+}