Refactor email fetching and handling logic

Consolidated email fetching queries into a unified `ReadEmailQuery` to support multiple protocols (IMAP, IMAP OAuth2, POP3) dynamically. Removed redundant query handlers (`ReadEmailViaImapQuery`, `ReadEmailViaOAuth2Query`, `ReadEmailViaPop3Query`) and merged their functionality into `ReadEmailQueryHandler` with protocol-specific methods.

Updated `FetchEmailsQueryValidator` to validate the new query. Refactored `EmailController` to use the unified query, simplifying endpoints and consolidating functionality. Removed obsolete endpoints for POP3 and OAuth2 fetching.

Performed code cleanup, including removing redundant imports and consolidating attachment handling logic. Updated project version to `2.0.0-beta` to reflect these significant changes. Improved documentation to align with the new structure.
This commit is contained in:
2026-08-18 15:26:21 +02:00
parent acf40684f8
commit 8450806916
7 changed files with 194 additions and 351 deletions

View File

@@ -0,0 +1,125 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Enums;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from an email account.
/// The protocol and authentication method are determined by <see cref="EmailAccount.IncomingProtocol"/>.
/// </summary>
public record ReadEmailQuery : IRequest<ReadEmailQueryResponse>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// Mail query used to filter and limit the emails retrieved.
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailQueryHandler(
IMapper Mapper,
ILogger<ReadEmailQueryHandler> Logger,
IRepository<EmailAccount> EmailAccountRepo,
IReceivedEmailRepository MailRepo,
IImapEmailService ImapEmailService,
IPop3EmailService Pop3EmailService) : IRequestHandler<ReadEmailQuery, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(
request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username,
cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.",
request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
return account.IncomingProtocol switch
{
IncomingProtocol.Imap => await HandleImapAsync(account, request.Mail, cancellationToken),
IncomingProtocol.ImapOAuth2 => await HandleImapOAuth2Async(account, request.Mail, cancellationToken),
IncomingProtocol.Pop3 => await HandlePop3Async(account, request.Mail, cancellationToken),
IncomingProtocol.Pop3OAuth2 => throw new NotImplementedException(
$"POP3 OAuth2 is not yet supported. Account: '{account.Username}' (Id: {account.Id})."),
IncomingProtocol.None => throw new BadRequestException(
$"Account '{account.Username}' (Id: {account.Id}) has no incoming protocol configured (IncomingProtocol = None)."),
_ => throw new BadRequestException(
$"Unknown IncomingProtocol value '{account.IncomingProtocol}' for account '{account.Username}' (Id: {account.Id}).")
};
}
private async Task<ReadEmailQueryResponse> HandleImapAsync(EmailAccount account, MailSearchFilter filter, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(filter, account, cancellationToken);
var lastSync = ImapEmailService.GetLastImapSyncDate(account.Id, filter.Folder);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
private async Task<ReadEmailQueryResponse> HandleImapOAuth2Async(EmailAccount account, MailSearchFilter filter, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId) || string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new BadRequestException(
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{account.Username}' (Id: {account.Id}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(filter, account, cancellationToken);
var lastSync = ImapEmailService.GetLastImapSyncDate(account.Id, filter.Folder);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
private async Task<ReadEmailQueryResponse> HandlePop3Async(EmailAccount account, MailSearchFilter filter, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(account.Pop3Server))
throw new BadRequestException(
$"POP3 is not configured for account '{account.Username}' (Id: {account.Id}). Set Pop3Server in EmailAccounts configuration.");
// Trigger on-demand POP3 sync before querying local storage
await Pop3EmailService.SyncEmailsAsync(account, cancellationToken);
// POP3 has no folder concept — always query INBOX
var pop3Filter = filter with { Folder = "INBOX" };
var mails = await MailRepo.FindAsync(pop3Filter, account, cancellationToken);
var lastSync = Pop3EmailService.GetLastPop3SyncDate(account.Id);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

@@ -1,58 +0,0 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from an IMAP mailbox.
/// </summary>
public record ReadEmailViaImapQuery : IRequest<ReadEmailQueryResponse>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// Mail query used to filter and limit the emails retrieved.
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailViaImapQueryHandler(IMapper Mapper, ILogger<ReadEmailViaImapQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo, IImapEmailService imapEmailService) : IRequestHandler<ReadEmailViaImapQuery, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaImapQuery request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

@@ -1,77 +0,0 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from an IMAP mailbox using OAuth2 authentication.
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </summary>
public record ReadEmailViaOAuth2Query : IRequest<ReadEmailQueryResponse>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// Mail query used to filter and limit the emails retrieved.
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailViaOAuth2QueryHandler(
IMapper Mapper,
ILogger<ReadEmailViaOAuth2QueryHandler> Logger,
IRepository<EmailAccount> EmailAccountRepo,
IReceivedEmailRepository MailRepo,
IImapEmailService imapEmailService) : IRequestHandler<ReadEmailViaOAuth2Query, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaOAuth2Query request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(
request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username,
cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (!account.UseOAuth2)
throw new BadRequestException(
$"Account '{account.Username}' (Id: {account.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
if (string.IsNullOrWhiteSpace(account.OAuth2ClientId) ||
string.IsNullOrWhiteSpace(account.OAuth2ClientSecret))
throw new BadRequestException(
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{account.Username}'.");
if (string.IsNullOrWhiteSpace(account.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
var lastSync = imapEmailService.GetLastImapSyncDate(account.Id, request.Mail.Folder);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

@@ -1,74 +0,0 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Application.EmailAccounts.Queries;
using DigitalData.MessagingService.Application.EmailReceiving.Queries;
using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions;
using MediatR;
using Microsoft.Extensions.Logging;
namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
/// <summary>
/// Query to fetch emails from a POP3 mailbox.
/// Triggers an on-demand sync and returns stored results filtered by <see cref="Mail"/>.
/// </summary>
public record ReadEmailViaPop3Query : IRequest<ReadEmailQueryResponse>
{
/// <summary>
/// Identifies the email account to use.
/// </summary>
public required GetEmailAccountQuery Account { get; init; }
/// <summary>
/// Mail query used to filter and limit the emails retrieved from local storage.
/// Note: POP3 has no folder concept — all messages are stored under "INBOX".
/// </summary>
public MailSearchFilter Mail { get; init; } = new();
}
public class ReadEmailViaPop3QueryHandler(
IMapper Mapper,
ILogger<ReadEmailViaPop3QueryHandler> Logger,
IRepository<EmailAccount> EmailAccountRepo,
IReceivedEmailRepository MailRepo,
IPop3EmailService pop3EmailService) : IRequestHandler<ReadEmailViaPop3Query, ReadEmailQueryResponse>
{
public async Task<ReadEmailQueryResponse> Handle(ReadEmailViaPop3Query request, CancellationToken cancellationToken)
{
var accounts = await EmailAccountRepo.FindAsync(
request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username,
cancellationToken: cancellationToken);
if (accounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
var account = accounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found (Id: {request.Account.Id}, Username: {request.Account.Username}).");
if (string.IsNullOrWhiteSpace(account.Pop3Server))
throw new BadRequestException(
$"POP3 is not configured for account '{account.Username}' (Id: {account.Id}). Set Pop3Server in EmailAccounts configuration.");
// Trigger on-demand POP3 sync before querying local storage
await pop3EmailService.SyncEmailsAsync(account, cancellationToken);
// POP3 has no folder concept — always query INBOX
var filter = request.Mail with { Folder = "INBOX" };
var mails = await MailRepo.FindAsync(filter, account, cancellationToken);
var lastSync = pop3EmailService.GetLastPop3SyncDate(account.Id);
return new ReadEmailQueryResponse
{
LastSync = lastSync,
Emails = Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails)
};
}
}
#endif

View File

@@ -5,9 +5,9 @@ using FluentValidation;
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
/// <summary>
/// Validates a <see cref="ReadEmailViaImapQuery"/> before it is handled by <see cref="ReadEmailViaImapQueryHandler"/>.
/// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
/// </summary>
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailViaImapQuery>
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
{
public FetchEmailsQueryValidator()
{