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; + } +}