refactor: Rename EmailPorifler to MessagingService
This commit is contained in:
@@ -0,0 +1,51 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Infrastructure.Messaging;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using DigitalData.MessagingService.Infrastructure.Services;
|
||||
using DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure;
|
||||
|
||||
/// <summary>
|
||||
/// Dependency injection configuration for Infrastructure layer
|
||||
/// </summary>
|
||||
public static class DependencyInjection
|
||||
{
|
||||
/// <summary>
|
||||
/// Adds Infrastructure layer services to the DI container
|
||||
/// </summary>
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
{
|
||||
// --- External Services ---
|
||||
// Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker)
|
||||
services.AddSingleton<IEmailService, LimilabsEmailService>();
|
||||
|
||||
// PDF Processing Service (using DevExpress.Pdf)
|
||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||
|
||||
// Encryption Service (using Data Protection API - Singleton, thread-safe)
|
||||
services.AddSingleton<IEncryptionService, DataProtectionEncryptionService>();
|
||||
|
||||
// --- Email Queue (RabbitMQ) ---
|
||||
services.AddSingleton<IOutgoingEmailQueue, OutgoingEmailQueue>();
|
||||
|
||||
// --- RabbitMQ Configuration ---
|
||||
services.Configure<RabbitMqConfiguration>(
|
||||
configuration.GetSection(RabbitMqConfiguration.SectionName));
|
||||
|
||||
// --- Data Protection (for encryption) ---
|
||||
services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(new DirectoryInfo(@"C:\ProgramData\EmailService\Keys"))
|
||||
.SetApplicationName("MessagingService");
|
||||
|
||||
// Register Background Workers
|
||||
services.AddHostedService<AsyncInitWorker>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<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" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AutoMapper" Version="16.2.0" />
|
||||
<PackageReference Include="DevExpress.Document.Processor" Version="26.1.3" />
|
||||
<PackageReference Include="MailKit" Version="4.17.0" />
|
||||
<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>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,54 @@
|
||||
namespace DigitalData.MessagingService.Infrastructure.Messaging;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for RabbitMQ connection
|
||||
/// </summary>
|
||||
public class RabbitMqConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Configuration section name in appsettings.json
|
||||
/// </summary>
|
||||
public const string SectionName = "RabbitMQ";
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ server hostname
|
||||
/// </summary>
|
||||
public string HostName { get; set; } = "localhost";
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ AMQP port (default: 5672)
|
||||
/// </summary>
|
||||
public int Port { get; set; } = 5672;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ username
|
||||
/// </summary>
|
||||
public string UserName { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ password
|
||||
/// </summary>
|
||||
public string Password { get; set; } = "guest";
|
||||
|
||||
/// <summary>
|
||||
/// Virtual host (default: /)
|
||||
/// </summary>
|
||||
public string VirtualHost { get; set; } = "/";
|
||||
|
||||
/// <summary>
|
||||
/// Enable automatic recovery on connection failure
|
||||
/// </summary>
|
||||
public bool AutomaticRecoveryEnabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Network recovery interval in seconds
|
||||
/// </summary>
|
||||
public int NetworkRecoveryIntervalSeconds { get; set; } = 10;
|
||||
|
||||
public string QueueName { get; set; } = null!;
|
||||
public string ExchangeName { get; set; } = null!;
|
||||
public string RoutingKey { get; set; } = null!;
|
||||
public string DlqQueueName { get; set; } = null!;
|
||||
public string DlqExchangeName { get; set; } = null!;
|
||||
public string DlqRoutingKey { get; set; } = null!;
|
||||
}
|
||||
@@ -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,217 @@
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Infrastructure.Messaging;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using RabbitMQ.Client;
|
||||
using RabbitMQ.Client.Events;
|
||||
using DigitalData.MessagingService.Application.Common.Events;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Queue;
|
||||
|
||||
/// <summary>
|
||||
/// RabbitMQ-based email queue implementation for outgoing emails.
|
||||
/// Provides message persistence, scalability, and reliability.
|
||||
/// Uses Lazy<T> initialization pattern to avoid blocking constructor.
|
||||
/// </summary>
|
||||
public sealed class OutgoingEmailQueue(IOptions<RabbitMqConfiguration> config, ILogger<OutgoingEmailQueue> Logger, IEmailService EmailService) : IOutgoingEmailQueue, IAsyncDisposable
|
||||
{
|
||||
private readonly RabbitMqConfiguration _config = config.Value;
|
||||
private IConnection? _connection = null;
|
||||
private IChannel? _publishChannel = null; // Dedicated channel for publishing
|
||||
private IChannel? _consumeChannel = null; // Dedicated channel for consuming
|
||||
private readonly CancellationTokenSource _consumerCts = new(); // Independent lifetime from InitAsync token
|
||||
|
||||
/// <summary>
|
||||
/// Initialize RabbitMQ connection, channel, exchanges, and queues asynchronously.
|
||||
/// Called lazily on first use via EnsureInitializedAsync.
|
||||
/// </summary>
|
||||
public async Task InitAsync(CancellationToken stoppingToken = default)
|
||||
{
|
||||
Logger.LogInformation("Initializing RabbitMQ connection and queues...");
|
||||
|
||||
var factory = new ConnectionFactory
|
||||
{
|
||||
HostName = _config.HostName,
|
||||
Port = _config.Port,
|
||||
UserName = _config.UserName,
|
||||
Password = _config.Password,
|
||||
VirtualHost = _config.VirtualHost,
|
||||
AutomaticRecoveryEnabled = _config.AutomaticRecoveryEnabled,
|
||||
NetworkRecoveryInterval = TimeSpan.FromSeconds(_config.NetworkRecoveryIntervalSeconds)
|
||||
};
|
||||
|
||||
_connection = await factory.CreateConnectionAsync(stoppingToken);
|
||||
|
||||
// Separate channels: IChannel is not thread-safe; one per role is best practice
|
||||
_publishChannel = await _connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
||||
_consumeChannel = await _connection.CreateChannelAsync(cancellationToken: stoppingToken);
|
||||
|
||||
// Topology declaration can use either channel; use publish channel here
|
||||
// Declare Dead Letter Queue (DLQ) exchange
|
||||
await _publishChannel.ExchangeDeclareAsync(exchange: _config.DlqExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: stoppingToken);
|
||||
|
||||
// Declare Dead Letter Queue (DLQ)
|
||||
await _publishChannel.QueueDeclareAsync(queue: _config.DlqQueueName, durable: true, exclusive: false, autoDelete: false, arguments: null, cancellationToken: stoppingToken);
|
||||
|
||||
// Bind DLQ to DLQ exchange
|
||||
await _publishChannel.QueueBindAsync(queue: _config.DlqQueueName, exchange: _config.DlqExchangeName, routingKey: _config.DlqRoutingKey, cancellationToken: stoppingToken);
|
||||
|
||||
// Declare main exchange (Direct type for routing)
|
||||
await _publishChannel.ExchangeDeclareAsync(exchange: _config.ExchangeName, type: ExchangeType.Direct, durable: true, autoDelete: false, cancellationToken: stoppingToken);
|
||||
|
||||
// Declare main queue (durable for persistence) with DLQ arguments
|
||||
var queueArgs = new Dictionary<string, object?>
|
||||
{
|
||||
{ "x-dead-letter-exchange", _config.DlqExchangeName },
|
||||
{ "x-dead-letter-routing-key", _config.DlqRoutingKey }
|
||||
};
|
||||
|
||||
await _publishChannel.QueueDeclareAsync(queue: _config.QueueName, durable: true, exclusive: false, autoDelete: false, arguments: queueArgs, cancellationToken: stoppingToken);
|
||||
|
||||
// Bind main queue to exchange with routing key
|
||||
await _publishChannel.QueueBindAsync(queue: _config.QueueName, exchange: _config.ExchangeName, routingKey: _config.RoutingKey, cancellationToken: stoppingToken);
|
||||
|
||||
// Consumer uses its own CancellationToken independent of the startup token,
|
||||
// so it keeps running after InitAsync completes or its token is cancelled.
|
||||
// Link stoppingToken so the consumer stops when the host stops.
|
||||
stoppingToken.Register(() => _consumerCts.Cancel());
|
||||
await StartConsumerAsync(_consumeChannel, _consumerCts.Token);
|
||||
|
||||
Logger.LogInformation("RabbitMQ initialized successfully: Queue={QueueName}, DLQ={DlqQueueName}", _config.QueueName, _config.DlqQueueName);
|
||||
}
|
||||
|
||||
public async Task EnqueueAsync(OutgoingEmailEvent outgoingEmailEvent, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(outgoingEmailEvent);
|
||||
var body = Encoding.UTF8.GetBytes(json);
|
||||
|
||||
var properties = new BasicProperties
|
||||
{
|
||||
Persistent = true, // Message persistence
|
||||
ContentType = "application/json",
|
||||
Timestamp = new AmqpTimestamp(DateTimeOffset.UtcNow.ToUnixTimeSeconds())
|
||||
};
|
||||
|
||||
await _publishChannel!.BasicPublishAsync(
|
||||
exchange: _config.ExchangeName,
|
||||
routingKey: _config.RoutingKey,
|
||||
mandatory: false,
|
||||
basicProperties: properties,
|
||||
body: body,
|
||||
cancellationToken: cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
var queueInfo = await _publishChannel!.QueueDeclarePassiveAsync(_config.QueueName, cancellationToken);
|
||||
return (int)queueInfo.MessageCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Start event-driven consumer that processes messages as they arrive
|
||||
/// </summary>
|
||||
private async Task StartConsumerAsync(IChannel consumeChannel, CancellationToken cancellationToken)
|
||||
{
|
||||
var consumer = new AsyncEventingBasicConsumer(consumeChannel);
|
||||
|
||||
consumer.ReceivedAsync += async (sender, args) =>
|
||||
{
|
||||
OutgoingEmailEvent? oMailEvent = null;
|
||||
try
|
||||
{
|
||||
var json = Encoding.UTF8.GetString(args.Body.ToArray());
|
||||
oMailEvent = JsonSerializer.Deserialize<OutgoingEmailEvent>(json);
|
||||
|
||||
if (oMailEvent is not null)
|
||||
{
|
||||
Logger.LogDebug("Received email message: To={To}, Subject={Subject}", oMailEvent.Recipient, oMailEvent.Subject);
|
||||
|
||||
Logger.LogInformation("Processing outgoing email: To={To}, Subject={Subject}",
|
||||
oMailEvent.Recipient, oMailEvent.Subject);
|
||||
|
||||
// Send email via SMTP (SMTP config is injected in IEmailService via IOptions)
|
||||
await EmailService.SendEmailAsync(
|
||||
oMailEvent.Recipient,
|
||||
oMailEvent.Subject,
|
||||
oMailEvent.Body,
|
||||
isHtml: oMailEvent.IsHtml);
|
||||
|
||||
Logger.LogInformation("Email sent successfully: To={To}, Subject={Subject}",
|
||||
oMailEvent.Recipient, oMailEvent.Subject);
|
||||
|
||||
// Acknowledge message after successful processing
|
||||
await consumeChannel.BasicAckAsync(args.DeliveryTag, false, args.CancellationToken);
|
||||
Logger.LogDebug("Message acknowledged: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.LogWarning("Failed to deserialize email message: DeliveryTag={DeliveryTag}", args.DeliveryTag);
|
||||
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // Don't requeue invalid messages
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "Failed to process email [To={To}, Subject={Subject}] message: DeliveryTag={DeliveryTag}. Moving to DLQ (NO retry).", oMailEvent?.Recipient, oMailEvent?.Subject, args.DeliveryTag);
|
||||
|
||||
// TODO: Error Reporting Strategy
|
||||
// Option 1: Separate RabbitMQ Queue (emailprofiler.errors)
|
||||
// - Create EmailErrorReport entity { OutgoingEmailEventId, Exception, StackTrace, Timestamp, RetryAttempt }
|
||||
// - Publish to error queue: await _errorQueue.EnqueueAsync(errorReport)
|
||||
// - Separate worker processes error queue → Log to DB/File/External monitoring
|
||||
//
|
||||
// Option 2: Database Table (TBEMLP_ERROR_LOG)
|
||||
// - Columns: ERROR_ID, OUTBOX_ID, ERROR_MESSAGE, STACK_TRACE, ERROR_DATE
|
||||
// - Insert via IErrorLogRepository.CreateAsync(errorLog)
|
||||
//
|
||||
// Option 3: External Monitoring Service
|
||||
// - Sentry: SentrySdk.CaptureException(ex)
|
||||
// - Application Insights: _telemetryClient.TrackException(ex)
|
||||
// - Elasticsearch: _elasticClient.IndexDocument(errorLog)
|
||||
//
|
||||
// Recommended: Option 1 (RabbitMQ Error Queue) + Option 2 (DB persistence)
|
||||
// - Fast async error logging (non-blocking)
|
||||
// - Persistent storage for audit
|
||||
// - Real-time alerting via monitoring worker
|
||||
|
||||
// NO RETRY - All failures move directly to DLQ
|
||||
await consumeChannel.BasicNackAsync(args.DeliveryTag, false, false, args.CancellationToken); // requeue=false → DLQ
|
||||
}
|
||||
};
|
||||
|
||||
// Start consuming messages (event-driven, non-blocking)
|
||||
await consumeChannel.BasicConsumeAsync(
|
||||
queue: _config.QueueName,
|
||||
autoAck: false,
|
||||
consumer: consumer,
|
||||
cancellationToken: cancellationToken);
|
||||
|
||||
Logger.LogInformation("RabbitMQ consumer started for queue: {QueueName}", _config.QueueName);
|
||||
}
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _consumerCts.CancelAsync();
|
||||
_consumerCts.Dispose();
|
||||
|
||||
if (_consumeChannel is not null)
|
||||
{
|
||||
await _consumeChannel.CloseAsync();
|
||||
await _consumeChannel.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_publishChannel is not null)
|
||||
{
|
||||
await _publishChannel.CloseAsync();
|
||||
await _publishChannel.DisposeAsync();
|
||||
}
|
||||
|
||||
if (_connection is not null)
|
||||
{
|
||||
await _connection.CloseAsync();
|
||||
await _connection.DisposeAsync();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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,32 @@
|
||||
using DigitalData.MessagingService.Application.Common.Interfaces;
|
||||
using DigitalData.MessagingService.Infrastructure.Queue;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||
|
||||
/// <summary>
|
||||
/// A hosted background service responsible for initializing the outgoing email queue consumer.
|
||||
/// Leverages a push-based, event-driven RabbitMQ consumer to eliminate polling overhead.
|
||||
/// Email account configuration is resolved exclusively from application settings; no database access is performed.
|
||||
/// </summary>
|
||||
public class AsyncInitWorker(IOutgoingEmailQueue EmailQueue, ILogger<AsyncInitWorker> Logger) : BackgroundService
|
||||
{
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Logger.LogInformation("Outgoing email queue worker is starting. Initializing event-driven RabbitMQ consumer.");
|
||||
|
||||
try
|
||||
{
|
||||
// Initialize the RabbitMQ push-based consumer. This call is non-blocking;
|
||||
// message processing is handled asynchronously via registered event callbacks.
|
||||
if (EmailQueue is OutgoingEmailQueue outgoingEmailQueue)
|
||||
await outgoingEmailQueue.InitAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.LogError(ex, "A critical error occurred while initializing the outgoing email queue consumer. The worker cannot proceed.");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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