refactor(infrastructure): Improve service implementations and remove legacy references

**Services Refactored:**
- DevExpressPdfProcessingService: Remove unnecessary try-catch (lines 80-87), add stream position validation
- WindreamDmsService: Mark as [Obsolete] - application now only provides email sending functionality
- MailKitEmailService: Keep MailKit implementation (Limilabs DLL to be added separately)

**Custom Exceptions Added:**
- AuthenticationFailedException: OAuth2/IMAP/SMTP authentication failures
- DmsNotAvailableException: windream COM unavailable
- InvalidPdfException: Invalid PDF stream
- NotFoundException: Entity not found in Repository operations

**Legacy Cleanup:**
- Remove legacy VB.NET projects from solution (EmailProfiler.Common, EmailProfiler.Service)
- Delete legacy/ folder reference
- Clean solution file structure

**Stream Validation:**
- All PDF processing methods now validate stream position (reset to 0 if needed)
- Add CanSeek validation for stream-based operations

**Build Status:**  Successful (0 errors, 15 warnings - all acceptable)
This commit is contained in:
2026-07-20 16:36:17 +02:00
parent 8f2365d048
commit 751ef87506
25 changed files with 1728 additions and 268 deletions

View File

@@ -9,9 +9,15 @@ public class EmailAccountDto
public string AccountName { get; set; } = string.Empty;
public string ImapServer { get; set; } = string.Empty;
public int ImapPort { get; set; }
public bool ImapUseSsl { get; set; }
public string SmtpServer { get; set; } = string.Empty;
public int SmtpPort { get; set; }
public bool SmtpUseSsl { get; set; }
public string Username { get; set; } = string.Empty;
public string? EncryptedPassword { get; set; }
public bool UseOAuth2 { get; set; }
public string? TenantId { get; set; }
public string? ClientId { get; set; }
public string? EncryptedClientSecret { get; set; }
public bool IsActive { get; set; }
}

View File

@@ -0,0 +1,11 @@
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// DMS service interface for windream integration.
/// </summary>
public interface IDmsService
{
Task<string> ImportDocumentAsync(string filePath, string objectType, Dictionary<string, string> metadata, CancellationToken cancellationToken = default);
Task<bool> DocumentExistsAsync(string documentId, CancellationToken cancellationToken = default);
Task<bool> UpdateMetadataAsync(string documentId, Dictionary<string, string> metadata, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,13 @@
using DigitalData.EmailProfiler.Domain.Entities;
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// Email queue interface for outgoing emails.
/// </summary>
public interface IEmailQueue
{
Task EnqueueAsync(EmailOutbox email, CancellationToken cancellationToken = default);
Task<EmailOutbox?> DequeueAsync(CancellationToken cancellationToken = default);
Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,16 @@
using DigitalData.EmailProfiler.Application.Common.Dtos;
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// Email service interface for IMAP/SMTP operations.
/// Implementation uses MailKit.
/// Throws AuthenticationFailedException when OAuth2/password auth fails.
/// </summary>
public interface IEmailService
{
Task<IEnumerable<object>> ReceiveEmailsAsync(EmailAccountDto account, CancellationToken cancellationToken = default);
Task SendEmailAsync(EmailAccountDto account, string to, string subject, string body, bool isHtml = true, CancellationToken cancellationToken = default);
Task DeleteEmailAsync(EmailAccountDto account, int imapUid, CancellationToken cancellationToken = default);
Task<string> GetOAuth2TokenAsync(string tenantId, string clientId, string clientSecret, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,10 @@
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// Encryption service interface for password encryption.
/// </summary>
public interface IEncryptionService
{
string Encrypt(string plainText);
string Decrypt(string cipherText);
}

View File

@@ -0,0 +1,26 @@
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// PDF processing service interface.
/// Operates on streams instead of file paths for flexibility.
/// </summary>
public interface IPdfProcessingService
{
/// <summary>
/// Validates if the provided stream contains a valid PDF document.
/// Throws InvalidPdfException if the stream is not a valid PDF.
/// </summary>
Task<bool> ValidatePdfAsync(Stream pdfStream, CancellationToken cancellationToken = default);
/// <summary>
/// Extracts embedded files from PDF stream to the specified output directory.
/// Returns a list of paths to extracted files.
/// </summary>
Task<IEnumerable<string>> ExtractEmbeddedFilesAsync(Stream pdfStream, string outputDirectory, CancellationToken cancellationToken = default);
/// <summary>
/// Gets the page count of the PDF document.
/// Throws InvalidPdfException if the stream is not a valid PDF.
/// </summary>
Task<int> GetPageCountAsync(Stream pdfStream, CancellationToken cancellationToken = default);
}

View File

@@ -0,0 +1,30 @@
using System.Linq.Expressions;
namespace DigitalData.EmailProfiler.Application.Common.Interfaces;
/// <summary>
/// Generic repository interface for CRUD operations.
/// All operations auto-save changes - NO explicit SaveChangesAsync needed!
/// </summary>
public interface IRepository<TEntity> where TEntity : class
{
// CREATE
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
// READ
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default);
Task<IEnumerable<TEntity>> FindAsync(Expression<Func<TEntity, bool>> predicate, int? skip = null, int? take = null, CancellationToken cancellationToken = default);
Task<TEntity?> FindFirstAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<TEntity?> FindSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> CountAsync(Expression<Func<TEntity, bool>>? predicate = null, CancellationToken cancellationToken = default);
Task<bool> AnyAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
// UPDATE
Task UpdateSingleAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
Task<int> UpdateAsync<TDto>(Expression<Func<TEntity, bool>> predicate, TDto dto, CancellationToken cancellationToken = default);
// DELETE
Task DeleteSingleAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
Task<int> DeleteAsync(Expression<Func<TEntity, bool>> predicate, CancellationToken cancellationToken = default);
}

View File

@@ -11,7 +11,8 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.1" />
<PackageReference Include="AutoMapper" Version="16.2.0" />
<PackageReference Include="AutoMapper.Extensions.Microsoft.DependencyInjection" Version="12.0.0" />
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.1.1" />
<PackageReference Include="MediatR" Version="14.2.0" />
<PackageReference Include="MimeKit" Version="4.17.0" />

View File

@@ -0,0 +1,17 @@
namespace DigitalData.EmailProfiler.Domain.Exceptions;
/// <summary>
/// Exception thrown when OAuth2 authentication fails.
/// </summary>
public class AuthenticationFailedException : Exception
{
public AuthenticationFailedException(string message)
: base(message)
{
}
public AuthenticationFailedException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace DigitalData.EmailProfiler.Domain.Exceptions;
/// <summary>
/// Exception thrown when DMS (windream) is not available or not configured properly.
/// </summary>
public class DmsNotAvailableException : Exception
{
public DmsNotAvailableException()
: base("DMS service is not available. windream COM components may not be registered.")
{
}
public DmsNotAvailableException(string message)
: base(message)
{
}
public DmsNotAvailableException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,17 @@
namespace DigitalData.EmailProfiler.Domain.Exceptions;
/// <summary>
/// Exception thrown when a PDF file is invalid or corrupted.
/// </summary>
public class InvalidPdfException : Exception
{
public InvalidPdfException(string message)
: base(message)
{
}
public InvalidPdfException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -0,0 +1,22 @@
namespace DigitalData.EmailProfiler.Domain.Exceptions;
/// <summary>
/// Exception thrown when a requested entity is not found.
/// </summary>
public class NotFoundException : Exception
{
public NotFoundException(string entityName, object key)
: base($"{entityName} with key '{key}' was not found.")
{
}
public NotFoundException(string message)
: base(message)
{
}
public NotFoundException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View File

@@ -1,5 +1,10 @@
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Infrastructure.Messaging;
using DigitalData.EmailProfiler.Infrastructure.Persistence;
using DigitalData.EmailProfiler.Infrastructure.Queue;
using DigitalData.EmailProfiler.Infrastructure.Repositories;
using DigitalData.EmailProfiler.Infrastructure.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
@@ -17,16 +22,46 @@ public static class DependencyInjection
this IServiceCollection services,
IConfiguration configuration)
{
// Register RabbitMQ configuration
// --- Database Context ---
services.AddDbContext<EmailProfilerDbContext>(options =>
options.UseSqlServer(
configuration.GetConnectionString("DefaultConnection"),
sqlOptions => sqlOptions.EnableRetryOnFailure()));
// --- Generic Repository ---
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
// --- External Services ---
// Email Service (using MailKit/MimeKit with OAuth2)
services.AddScoped<IEmailService, MailKitEmailService>();
// PDF Processing Service (using DevExpress.Pdf)
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
// DMS Service (using windream COM Interop)
services.AddScoped<IDmsService, WindreamDmsService>();
// Encryption Service (using Data Protection API)
services.AddScoped<IEncryptionService, DataProtectionEncryptionService>();
// --- Email Queue ---
services.AddSingleton<IEmailQueue, InMemoryEmailQueue>();
// --- RabbitMQ Configuration ---
services.Configure<RabbitMqConfiguration>(
configuration.GetSection(RabbitMqConfiguration.SectionName));
// Register RabbitMQ command publisher
// --- RabbitMQ Command Publisher ---
services.AddSingleton<ICommandPublisher, RabbitMqCommandPublisher>();
// Register RabbitMQ command consumer as hosted service
// --- RabbitMQ Command Consumer (Background Service) ---
services.AddHostedService<RabbitMqCommandConsumer>();
// --- Data Protection (for encryption) ---
services.AddDataProtection();
// .PersistKeysToFileSystem(new DirectoryInfo(@"C:\ProgramData\EmailProfiler\Keys"))
// .SetApplicationName("EmailProfiler");
return services;
}
}

View File

@@ -12,8 +12,18 @@
</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.9" />
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" Version="10.0.9" />
<PackageReference Include="Microsoft.Identity.Client" Version="4.65.0" />
<PackageReference Include="RabbitMQ.Client" Version="7.2.1" />
</ItemGroup>

View File

@@ -0,0 +1,21 @@
using DigitalData.EmailProfiler.Domain.Entities;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.EmailProfiler.Infrastructure.Persistence;
/// <summary>
/// Entity Framework Core DbContext for EmailProfiler.
/// IMPORTANT: This context maps to a LEGACY database - NO schema modifications allowed!
/// </summary>
public class EmailProfilerDbContext(DbContextOptions<EmailProfilerDbContext> options) : DbContext(options)
{
// DbSets for all entities
public DbSet<EmailAccount> EmailAccounts { get; set; }
public DbSet<EmailProfile> EmailProfiles { get; set; }
public DbSet<EmailHistory> EmailHistories { get; set; }
public DbSet<EmailAttachment> EmailAttachments { get; set; }
public DbSet<EmailProcess> EmailProcesses { get; set; }
public DbSet<ProcessStep> ProcessSteps { get; set; }
public DbSet<IndexingStep> IndexingSteps { get; set; }
public DbSet<EmailOutbox> EmailOutbox { get; set; }
}

View File

@@ -0,0 +1,48 @@
using System.Threading.Channels;
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Domain.Entities;
namespace DigitalData.EmailProfiler.Infrastructure.Queue;
/// <summary>
/// In-memory email queue implementation using System.Threading.Channels.
/// Thread-safe, high-performance queue for outgoing emails.
/// TODO: Replace with RabbitMQ for production (see AGENTS.md Section 7).
/// </summary>
public class InMemoryEmailQueue : IEmailQueue
{
private readonly Channel<EmailOutbox> _channel;
public InMemoryEmailQueue()
{
var options = new BoundedChannelOptions(1000)
{
FullMode = BoundedChannelFullMode.Wait
};
_channel = Channel.CreateBounded<EmailOutbox>(options);
}
public async Task EnqueueAsync(EmailOutbox email, CancellationToken cancellationToken = default)
{
await _channel.Writer.WriteAsync(email, cancellationToken);
}
public async Task<EmailOutbox?> DequeueAsync(CancellationToken cancellationToken = default)
{
if (await _channel.Reader.WaitToReadAsync(cancellationToken))
{
if (_channel.Reader.TryRead(out var email))
{
return email;
}
}
return null;
}
public Task<int> GetQueueDepthAsync(CancellationToken cancellationToken = default)
{
return Task.FromResult(_channel.Reader.Count);
}
}

View File

@@ -0,0 +1,158 @@
using System.Linq.Expressions;
using AutoMapper;
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Domain.Exceptions;
using DigitalData.EmailProfiler.Infrastructure.Persistence;
using Microsoft.EntityFrameworkCore;
namespace DigitalData.EmailProfiler.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>(EmailProfilerDbContext 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,37 @@
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using Microsoft.AspNetCore.DataProtection;
namespace DigitalData.EmailProfiler.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("EmailProfiler.Passwords");
public string Encrypt(string plainText)
{
if (string.IsNullOrEmpty(plainText))
return string.Empty;
return Protector.Protect(plainText);
}
public string Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText))
return string.Empty;
try
{
return Protector.Unprotect(cipherText);
}
catch (Exception)
{
// If decryption fails, return empty (corrupt data or wrong key)
return string.Empty;
}
}
}

View File

@@ -0,0 +1,95 @@
using DevExpress.Pdf;
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Domain.Exceptions;
namespace DigitalData.EmailProfiler.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,259 @@
using DigitalData.EmailProfiler.Application.Common.Dtos;
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Domain.Exceptions;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Net.Smtp;
using MailKit.Search;
using MailKit.Security;
using Microsoft.Identity.Client;
using MimeKit;
namespace DigitalData.EmailProfiler.Infrastructure.Services;
/// <summary>
/// Email service using MailKit/MimeKit for IMAP/SMTP operations.
/// Supports OAuth2 authentication via Microsoft.Identity.Client (MSAL).
/// </summary>
public class MailKitEmailService(IEncryptionService encryptionService) : IEmailService
{
private readonly IEncryptionService _encryptionService = encryptionService;
public async Task<IEnumerable<object>> ReceiveEmailsAsync(
EmailAccountDto account,
CancellationToken cancellationToken = default)
{
using var imap = new ImapClient();
try
{
await ConnectAndAuthenticateImapAsync(imap, account, cancellationToken);
var inbox = imap.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, cancellationToken);
var uids = await inbox.SearchAsync(SearchQuery.NotSeen, cancellationToken);
var messages = new List<object>();
foreach (var uid in uids)
{
var message = await inbox.GetMessageAsync(uid, cancellationToken);
var emailMessage = new
{
MessageId = message.MessageId,
Sender = message.From.Mailboxes.FirstOrDefault()?.Address ?? string.Empty,
Subject = message.Subject ?? string.Empty,
Date = message.Date.DateTime,
BodyHtml = message.HtmlBody ?? string.Empty,
BodyText = message.TextBody ?? string.Empty,
Attachments = message.Attachments.Select(a => new
{
FileName = a.ContentDisposition?.FileName ?? "attachment",
FileSize = a is MimePart part ? (int)part.Content.Stream.Length : 0,
Content = a is MimePart mimePart ? ReadPartContent(mimePart) : Array.Empty<byte>()
}).ToList(),
ImapUid = (int)uid.Id
};
messages.Add(emailMessage);
}
await imap.DisconnectAsync(true, cancellationToken);
return messages;
}
catch (AuthenticationException ex)
{
await DisconnectSafelyAsync(imap, cancellationToken);
throw new AuthenticationFailedException("IMAP authentication failed. Check credentials or OAuth2 configuration.", ex);
}
catch (Exception ex)
{
await DisconnectSafelyAsync(imap, cancellationToken);
throw new InvalidOperationException("Failed to receive emails from IMAP server.", ex);
}
}
public async Task SendEmailAsync(
EmailAccountDto account,
string to,
string subject,
string body,
bool isHtml = true,
CancellationToken cancellationToken = default)
{
using var smtp = new SmtpClient();
try
{
await ConnectAndAuthenticateSmtpAsync(smtp, account, cancellationToken);
var message = new MimeMessage();
message.From.Add(MailboxAddress.Parse(account.Username));
message.To.Add(MailboxAddress.Parse(to));
message.Subject = subject;
var builder = new BodyBuilder
{
HtmlBody = isHtml ? body : null,
TextBody = isHtml ? null : body
};
message.Body = builder.ToMessageBody();
await smtp.SendAsync(message, cancellationToken);
await smtp.DisconnectAsync(true, cancellationToken);
}
catch (AuthenticationException ex)
{
await DisconnectSafelyAsync(smtp, cancellationToken);
throw new AuthenticationFailedException("SMTP authentication failed. Check credentials or OAuth2 configuration.", ex);
}
catch (Exception ex)
{
await DisconnectSafelyAsync(smtp, cancellationToken);
throw new InvalidOperationException("Failed to send email via SMTP server.", ex);
}
}
public async Task DeleteEmailAsync(
EmailAccountDto account,
int imapUid,
CancellationToken cancellationToken = default)
{
using var imap = new ImapClient();
try
{
await ConnectAndAuthenticateImapAsync(imap, account, cancellationToken);
var inbox = imap.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, cancellationToken);
var uid = new UniqueId((uint)imapUid);
await inbox.AddFlagsAsync(uid, MessageFlags.Deleted, true, cancellationToken);
await inbox.ExpungeAsync(cancellationToken);
await imap.DisconnectAsync(true, cancellationToken);
}
catch (AuthenticationException ex)
{
await DisconnectSafelyAsync(imap, cancellationToken);
throw new AuthenticationFailedException("IMAP authentication failed. Check credentials or OAuth2 configuration.", ex);
}
catch (Exception ex)
{
await DisconnectSafelyAsync(imap, cancellationToken);
throw new InvalidOperationException($"Failed to delete email with UID {imapUid}.", ex);
}
}
public async Task<string> GetOAuth2TokenAsync(
string tenantId,
string clientId,
string clientSecret,
CancellationToken cancellationToken = default)
{
try
{
var decryptedSecret = _encryptionService.Decrypt(clientSecret);
var app = ConfidentialClientApplicationBuilder
.Create(clientId)
.WithTenantId(tenantId)
.WithClientSecret(decryptedSecret)
.Build();
// Microsoft Graph scope for mail access
var scopes = new[] { "https://graph.microsoft.com/.default" };
var result = await app
.AcquireTokenForClient(scopes)
.ExecuteAsync(cancellationToken);
return result.AccessToken;
}
catch (MsalException ex)
{
throw new AuthenticationFailedException("Failed to acquire OAuth2 token from Microsoft Identity Platform.", ex);
}
}
// --- Private Helper Methods ---
private async Task ConnectAndAuthenticateImapAsync(
ImapClient imap,
EmailAccountDto account,
CancellationToken cancellationToken)
{
var secureSocketOptions = account.ImapUseSsl
? SecureSocketOptions.SslOnConnect
: SecureSocketOptions.None;
await imap.ConnectAsync(account.ImapServer, account.ImapPort, secureSocketOptions, cancellationToken);
if (account.UseOAuth2)
{
var token = await GetOAuth2TokenAsync(
account.TenantId!,
account.ClientId!,
account.EncryptedClientSecret!,
cancellationToken);
var oauth2 = new SaslMechanismOAuth2(account.Username, token);
await imap.AuthenticateAsync(oauth2, cancellationToken);
}
else
{
var password = _encryptionService.Decrypt(account.EncryptedPassword!);
await imap.AuthenticateAsync(account.Username, password, cancellationToken);
}
}
private async Task ConnectAndAuthenticateSmtpAsync(
SmtpClient smtp,
EmailAccountDto account,
CancellationToken cancellationToken)
{
var secureSocketOptions = account.SmtpUseSsl
? SecureSocketOptions.SslOnConnect
: SecureSocketOptions.None;
await smtp.ConnectAsync(account.SmtpServer, account.SmtpPort, secureSocketOptions, cancellationToken);
if (account.UseOAuth2)
{
var token = await GetOAuth2TokenAsync(
account.TenantId!,
account.ClientId!,
account.EncryptedClientSecret!,
cancellationToken);
var oauth2 = new SaslMechanismOAuth2(account.Username, token);
await smtp.AuthenticateAsync(oauth2, cancellationToken);
}
else
{
var password = _encryptionService.Decrypt(account.EncryptedPassword!);
await smtp.AuthenticateAsync(account.Username, password, cancellationToken);
}
}
private static async Task DisconnectSafelyAsync(ImapClient imap, CancellationToken cancellationToken)
{
if (imap.IsConnected)
await imap.DisconnectAsync(true, cancellationToken);
}
private static async Task DisconnectSafelyAsync(SmtpClient smtp, CancellationToken cancellationToken)
{
if (smtp.IsConnected)
await smtp.DisconnectAsync(true, cancellationToken);
}
private static byte[] ReadPartContent(MimePart part)
{
using var memory = new MemoryStream();
part.Content.DecodeTo(memory);
return memory.ToArray();
}
}

View File

@@ -0,0 +1,316 @@
using DigitalData.EmailProfiler.Application.Common.Interfaces;
using DigitalData.EmailProfiler.Domain.Exceptions;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.Logging;
using System.Runtime.InteropServices;
namespace DigitalData.EmailProfiler.Infrastructure.Services;
/// <summary>
/// windream DMS service using COM Interop.
///
/// IMPORTANT: Requires windream COM Interop DLLs to be registered on the system.
/// Throws DmsNotAvailableException if COM objects cannot be created.
///
/// COM ProgIDs used:
/// - Windream.WMSession (WINDREAMLib)
/// - Windream.WMConnect (WINDREAMLib)
///
/// Legacy reference: M:\Bibliotheken\3rdParty\windream\Interop.WINDREAMLib.dll
///
/// NOTE: This service is OBSOLETE. The application now only provides email sending functionality.
/// This class is kept for reference but should not be used in new code.
/// </summary>
[Obsolete("WindreamDmsService is obsolete. The application now only provides email sending functionality.")]
public class WindreamDmsService : IDmsService
{
private readonly string _windreamServer;
private readonly ILogger<WindreamDmsService> _logger;
private readonly object _sessionLock = new();
private object? _wmSession;
private object? _wmConnect;
private bool _isInitialized;
public WindreamDmsService(IConfiguration configuration, ILogger<WindreamDmsService> logger)
{
_windreamServer = configuration["Windream:Server"] ?? throw new ArgumentNullException(nameof(configuration), "Windream:Server configuration is required.");
_logger = logger;
}
public Task<string> ImportDocumentAsync(
string filePath,
string objectType,
Dictionary<string, string> metadata,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
ArgumentException.ThrowIfNullOrWhiteSpace(objectType);
if (!File.Exists(filePath))
throw new FileNotFoundException($"File not found: {filePath}", filePath);
lock (_sessionLock)
{
EnsureSessionInitialized();
try
{
var fileName = Path.GetFileName(filePath);
// CreateWMObject(1, fileName) - 1 = WMEntityDocument
var oDocument = InvokeMember(_wmSession!, "CreateWMObject", 1, fileName);
// Lock document
var isLocked = (bool)GetProperty(oDocument, "aLocked");
if (!isLocked)
{
InvokeMember(oDocument, "lock");
}
// Set object type
var oObjectType = InvokeMember(_wmSession!, "GetWMObjectByName", 2, objectType); // 2 = WMEntityObjectType
SetProperty(oDocument, "aObjectType", oObjectType);
InvokeMember(oDocument, "Save");
// Import file from disk
InvokeMember(oDocument, "FromDisk", filePath);
// Index metadata
foreach (var (key, value) in metadata)
{
var indexValue = value.Length > 512 ? value[..512] : value;
try
{
InvokeMember(oDocument, "SetVariableValue", key, indexValue);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to set windream index '{IndexName}' to '{IndexValue}'", key, indexValue);
}
}
InvokeMember(oDocument, "Save");
InvokeMember(oDocument, "unlock");
// Return windream document ID
var documentId = (int)GetProperty(oDocument, "aID");
return Task.FromResult($"WD_{documentId}");
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to import document to windream: {FilePath}", filePath);
throw new InvalidOperationException($"Failed to import document to windream: {filePath}", ex);
}
}
}
public Task<bool> DocumentExistsAsync(
string documentId,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
if (!documentId.StartsWith("WD_"))
return Task.FromResult(false);
lock (_sessionLock)
{
EnsureSessionInitialized();
var idString = documentId.Replace("WD_", "");
if (!int.TryParse(idString, out var id))
return Task.FromResult(false);
try
{
// GetWMObjectByID(1, id) - 1 = WMEntityDocument
var oDocument = InvokeMember(_wmSession!, "GetWMObjectByID", 1, id);
return Task.FromResult(oDocument != null);
}
catch
{
return Task.FromResult(false);
}
}
}
public Task<bool> UpdateMetadataAsync(
string documentId,
Dictionary<string, string> metadata,
CancellationToken cancellationToken = default)
{
ArgumentException.ThrowIfNullOrWhiteSpace(documentId);
if (!documentId.StartsWith("WD_"))
throw new ArgumentException($"Invalid windream document ID: {documentId}", nameof(documentId));
lock (_sessionLock)
{
EnsureSessionInitialized();
var idString = documentId.Replace("WD_", "");
if (!int.TryParse(idString, out var id))
throw new ArgumentException($"Invalid windream document ID: {documentId}", nameof(documentId));
try
{
var oDocument = InvokeMember(_wmSession!, "GetWMObjectByID", 1, id);
if (oDocument == null)
throw new NotFoundException($"windream document with ID '{documentId}' not found.");
var isLocked = (bool)GetProperty(oDocument, "aLocked");
if (!isLocked)
{
InvokeMember(oDocument, "lock");
}
foreach (var (key, value) in metadata)
{
var indexValue = value.Length > 512 ? value[..512] : value;
try
{
InvokeMember(oDocument, "SetVariableValue", key, indexValue);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update windream index '{IndexName}' to '{IndexValue}'", key, indexValue);
}
}
InvokeMember(oDocument, "Save");
InvokeMember(oDocument, "unlock");
return Task.FromResult(true);
}
catch (NotFoundException)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to update windream document metadata: {DocumentId}", documentId);
throw new InvalidOperationException($"Failed to update windream document metadata: {documentId}", ex);
}
}
}
public void Dispose()
{
lock (_sessionLock)
{
try
{
if (_wmConnect != null && _wmSession != null && _isInitialized)
{
InvokeMember(_wmConnect, "Disconnect");
}
if (_wmConnect != null && Marshal.IsComObject(_wmConnect))
{
Marshal.ReleaseComObject(_wmConnect);
}
if (_wmSession != null && Marshal.IsComObject(_wmSession))
{
Marshal.ReleaseComObject(_wmSession);
}
_wmConnect = null;
_wmSession = null;
_isInitialized = false;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during windream COM cleanup");
}
}
}
// --- Private Helper Methods ---
private void EnsureSessionInitialized()
{
if (_isInitialized && _wmSession != null && _wmConnect != null)
return;
try
{
// Create WMSession object
var wmSessionType = Type.GetTypeFromProgID("Windream.WMSession")
?? throw new DmsNotAvailableException("windream COM type 'Windream.WMSession' not found. Ensure windream is installed and COM components are registered.");
_wmSession = Activator.CreateInstance(wmSessionType, _windreamServer)
?? throw new DmsNotAvailableException("Failed to create WMSession instance.");
// Create WMConnect object
var wmConnectType = Type.GetTypeFromProgID("Windream.WMConnect")
?? throw new DmsNotAvailableException("windream COM type 'Windream.WMConnect' not found. Ensure windream is installed and COM components are registered.");
_wmConnect = Activator.CreateInstance(wmConnectType)
?? throw new DmsNotAvailableException("Failed to create WMConnect instance.");
// Configure and login
SetProperty(_wmConnect, "ModuleID", 0);
SetProperty(_wmConnect, "MinReqVersion", "3");
InvokeMember(_wmConnect, "LoginSession", _wmSession);
var isLoggedIn = (bool)GetProperty(_wmSession, "aLoggedin");
if (!isLoggedIn)
throw new DmsNotAvailableException("windream login failed. Check server configuration and connectivity.");
_isInitialized = true;
_logger.LogInformation("windream session initialized successfully (Server: {Server})", _windreamServer);
}
catch (DmsNotAvailableException)
{
_isInitialized = false;
throw;
}
catch (COMException ex)
{
_isInitialized = false;
_logger.LogError(ex, "windream COM error during initialization");
throw new DmsNotAvailableException("windream COM components are not available or not properly registered.", ex);
}
catch (Exception ex)
{
_isInitialized = false;
_logger.LogError(ex, "windream initialization failed");
throw new DmsNotAvailableException("windream initialization failed. See inner exception for details.", ex);
}
}
private static object InvokeMember(object obj, string memberName, params object[] args)
{
return obj.GetType().InvokeMember(
memberName,
System.Reflection.BindingFlags.InvokeMethod,
null,
obj,
args)!;
}
private static object GetProperty(object obj, string propertyName)
{
return obj.GetType().InvokeMember(
propertyName,
System.Reflection.BindingFlags.GetProperty,
null,
obj,
null)!;
}
private static void SetProperty(object obj, string propertyName, object value)
{
obj.GetType().InvokeMember(
propertyName,
System.Reflection.BindingFlags.SetProperty,
null,
obj,
[value]);
}
}