Refactor solution structure and add RabbitMQ config
Reorganized the solution structure to align with a layered architecture: - Replaced `src` folder with `core`, `infrastructure`, and `presentation`. - Moved projects to their respective folders. - Added `DigitalData.MessagingService.Publisher.Abstraction` project. - Removed `DigitalData.MessagingService.Client` project. Updated project configurations and nesting in the solution file. Added `appsettings.Secrets.json` with RabbitMQ and email account settings: - RabbitMQ configuration includes hostname, port, credentials, and queue/exchange details. - Email configuration includes SMTP server details and credentials.
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using DigitalData.MessagingService.Infrastructure.Services;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection configuration for Infrastructure layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Infrastructure layer services to the DI container
|
||||
/// </summary>
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// --- External Services ---
|
||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
||||
|
||||
// PDF Processing Service (using DevExpress.Pdf)
|
||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||
|
||||
// Encryption Service (using Data Protection API - Singleton, thread-safe)
|
||||
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
||||
|
||||
// --- Email Queue (RabbitMQ) ---
|
||||
services.AddSingleton<OutgoingEmailConsumer>();
|
||||
services.AddSingleton<IOutgoingEmailPublisher, OutgoingEmailPublisher>();
|
||||
|
||||
// --- 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>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net8.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Domain\DigitalData.MessagingService.Domain.csproj" />
|
||||
<ProjectReference Include="..\DigitalData.MessagingService.Application\DigitalData.MessagingService.Application.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.Options.ConfigurationExtensions" Version="10.0.10" />
|
||||
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
|
||||
<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,11 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Persistence;
|
||||
|
||||
/// <summary>
|
||||
/// Entity Framework Core DbContext for MessagingService.
|
||||
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
|
||||
/// </summary>
|
||||
public class MessagingServiceDbContext(DbContextOptions<MessagingServiceDbContext> options) : DbContext(options)
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Publisher.Abstraction;
|
||||
using DigitalData.MessagingService.RabbitMQ;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
||||
/// Provides message persistence, scalability, and reliability.
|
||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
||||
/// </summary>
|
||||
public sealed class OutgoingEmailConsumer : IAsyncDisposable
|
||||
{
|
||||
private readonly RabbitMqConfiguration _config;
|
||||
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
|
||||
private readonly Lazy<Task> _lazyInit;
|
||||
|
||||
private readonly ILogger<OutgoingEmailConsumer>? _logger;
|
||||
|
||||
public OutgoingEmailConsumer(IOptions<RabbitMqConfiguration> config, IEmailService EmailService, RabbitMqConnectionFactory CnnFactory, ILogger<OutgoingEmailConsumer>? logger = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_config = config.Value;
|
||||
|
||||
_lazyChannel = new(CnnFactory.CreateChannelAsync);
|
||||
_lazyInit = new(async () => {
|
||||
var channel = await _lazyChannel.Value;
|
||||
|
||||
var consumer = new AsyncEventingBasicConsumer(channel);
|
||||
|
||||
consumer.ReceivedAsync += async (sender, args) =>
|
||||
{
|
||||
OutgoingEmailEvent? oMailEvent = null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
||||
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
|
||||
|
||||
if (oMailEvent is not null)
|
||||
{
|
||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||
await EmailService.SendEmailAsync(
|
||||
oMailEvent.Recipient,
|
||||
oMailEvent.Subject,
|
||||
oMailEvent.Body,
|
||||
isHtml: oMailEvent.IsHtml);
|
||||
|
||||
// Acknowledge message after successful processing
|
||||
await channel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
||||
}
|
||||
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?.Recipient, oMailEvent?.Subject, args.DeliveryTag);
|
||||
|
||||
// TODO: Error Reporting Strategy
|
||||
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
||||
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
|
||||
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
||||
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
||||
//
|
||||
// Option 2: Database Table (TBEMLP_ERROR_LOG)
|
||||
// - Columns: ERROR_ID, OUTBOX_ID, ERROR_MESSAGE, STACK_TRACE, ERROR_DATE
|
||||
// - Insert via IErrorLogRepository.CreateAsync(errorLog)
|
||||
//
|
||||
// Option 3: External Monitoring Service
|
||||
// - Sentry: SentrySdk.CaptureException(ex)
|
||||
// - Application Insights: _telemetryClient.TrackException(ex)
|
||||
// - Elasticsearch: _elasticClient.IndexDocument(errorLog)
|
||||
//
|
||||
// Recommended: Option 1 (RabbitMQ Error Queue) + Option 2 (DB persistence)
|
||||
// - Fast async error logging (non-blocking)
|
||||
// - Persistent storage for audit
|
||||
// - Real-time alerting via monitoring worker
|
||||
|
||||
// NO RETRY - All failures move directly to DLQ
|
||||
await channel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
|
||||
}
|
||||
};
|
||||
|
||||
// Start consuming messages (event-driven, non-blocking)
|
||||
await channel.BasicConsumeAsync(
|
||||
queue: _config.QueueName,
|
||||
autoAck: false,
|
||||
consumer: consumer,
|
||||
cancellationToken: CnnFactory.CancellationToken);
|
||||
|
||||
logger?.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
||||
/// Start event-driven consumer that processes messages as they arrive
|
||||
/// Called lazily on first use via EnsureInitializedAsync.
|
||||
/// </summary>
|
||||
public async Task InitAsync()
|
||||
{
|
||||
if (_lazyInit.IsValueCreated)
|
||||
_logger?.LogWarning("OutgoingEmailConsumer 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,107 @@
|
||||
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.Publisher.Abstraction;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
||||
/// Provides message persistence, scalability, and reliability.
|
||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
||||
/// </summary>
|
||||
public sealed class OutgoingEmailPublisher : IOutgoingEmailPublisher, IAsyncDisposable
|
||||
{
|
||||
private readonly RabbitMqConfiguration _config;
|
||||
private readonly ILogger<OutgoingEmailPublisher> _logger;
|
||||
private readonly RabbitMqConnectionFactory _cnnFactory;
|
||||
private readonly Lazy<Task<IChannel>> _lazyChannel;
|
||||
|
||||
public OutgoingEmailPublisher(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailPublisher> 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(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(outgoingEmailEvent);
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true, // Message persistence
|
||||
ContentType = "application/json",
|
||||
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
||||
};
|
||||
|
||||
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,158 @@
|
||||
using System.Linq.Expressions;
|
||||
using AutoMapper;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
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
|
||||
{
|
||||
private 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;
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
|
||||
foreach (var entity in entities)
|
||||
{
|
||||
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,20 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted background service responsible for initializing the outgoing email queue consumer.
|
||||
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
||||
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
||||
/// </summary>
|
||||
public class AsyncInitWorker(OutgoingEmailConsumer EmailConsumer) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
await EmailConsumer.InitAsync();
|
||||
|
||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||
}
|
||||
}
|
||||
@@ -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,111 @@
|
||||
using System.Text;
|
||||
using DigitalData.MessagingService.Application.Common.Dtos;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Domain.Exceptions;
|
||||
using Limilabs.Client.SMTP;
|
||||
using Limilabs.Mail;
|
||||
using Limilabs.Mail.Headers;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Email service using Limilabs Mail.dll for SMTP operations (send-only).
|
||||
/// Commercial-grade library with superior Exchange support.
|
||||
/// SMTP configuration is injected via IOptions<EmailAccountDto> from appsettings.json.
|
||||
/// </summary>
|
||||
public class LimilabsEmailService(
|
||||
IEncryptionService encryptionService,
|
||||
IOptions<EmailAccountDto> smtpConfig) : IEmailService
|
||||
{
|
||||
private readonly EmailAccountDto _smtpAccount = smtpConfig.Value;
|
||||
|
||||
// Register encoding provider for Limilabs (requires windows-1252 and other code pages)
|
||||
static LimilabsEmailService()
|
||||
{
|
||||
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
|
||||
}
|
||||
|
||||
public async Task SendEmailAsync(string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default)
|
||||
{
|
||||
using var smtp = new Smtp();
|
||||
|
||||
try
|
||||
{
|
||||
await ConnectAndAuthenticateSmtpAsync(smtp);
|
||||
|
||||
var builder = new MailBuilder();
|
||||
builder.From.Add(new MailBox(_smtpAccount.Username));
|
||||
builder.To.Add(new MailBox(to));
|
||||
builder.Subject = subject;
|
||||
|
||||
if (isHtml)
|
||||
{
|
||||
builder.Html = body;
|
||||
}
|
||||
else
|
||||
{
|
||||
builder.Text = body;
|
||||
}
|
||||
|
||||
var mail = builder.Create();
|
||||
|
||||
var result = smtp.SendMessage(mail);
|
||||
|
||||
if (result.Status != SendMessageStatus.Success)
|
||||
{
|
||||
throw new InvalidOperationException($"Failed to send email. Status: {result.Status}");
|
||||
}
|
||||
|
||||
smtp.Close();
|
||||
await Task.CompletedTask; // For async consistency
|
||||
}
|
||||
catch (Limilabs.Client.ServerException ex)
|
||||
{
|
||||
DisconnectSafely(smtp);
|
||||
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DisconnectSafely(smtp);
|
||||
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Private Helper Methods ---
|
||||
|
||||
private async Task ConnectAndAuthenticateSmtpAsync(Smtp smtp)
|
||||
{
|
||||
if (_smtpAccount.SmtpUseSsl)
|
||||
{
|
||||
smtp.ConnectSSL(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
|
||||
}
|
||||
else
|
||||
{
|
||||
smtp.Connect(_smtpAccount.SmtpServer, _smtpAccount.SmtpPort);
|
||||
}
|
||||
|
||||
if (_smtpAccount.UseOAuth2)
|
||||
{
|
||||
throw new NotSupportedException("OAuth2 is not configured for this SMTP account. UseOAuth2 must be false.");
|
||||
}
|
||||
else
|
||||
{
|
||||
var password = _smtpAccount.PasswordEncrypted ? encryptionService.Decrypt(_smtpAccount.Password) : _smtpAccount.Password;
|
||||
|
||||
smtp.Login(_smtpAccount.Username, password);
|
||||
}
|
||||
|
||||
await Task.CompletedTask; // For async consistency
|
||||
}
|
||||
|
||||
private static void DisconnectSafely(Smtp smtp)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (smtp.Connected)
|
||||
smtp.Close();
|
||||
}
|
||||
catch { /* Ignore disconnect errors */ }
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user