refactor: Rename EmailPorifler to MessagingService

This commit is contained in:
2026-07-24 13:59:43 +02:00
parent 77d3b52d16
commit 5e587da957
58 changed files with 202 additions and 202 deletions

View File

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

View File

@@ -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>

View File

@@ -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!;
}

View File

@@ -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)
{
}

View File

@@ -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();
}
}
}

View File

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

View File

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

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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&lt;EmailAccountDto&gt; 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 */ }
}
}