feat(application): add PublishEmailViaImap/OAuth2 commands and ReadEmailViaPop3/OAuth2 queries with handlers

This commit is contained in:
2026-08-17 10:14:59 +02:00
parent a57429718d
commit f7e95eb7f7
4 changed files with 311 additions and 0 deletions

View File

@@ -0,0 +1,77 @@
#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

@@ -0,0 +1,74 @@
#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

@@ -0,0 +1,81 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
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;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email using IMAP account credentials (queued via RabbitMQ).
/// After processing, the sent message is appended to the IMAP Sent Items folder.
/// </summary>
public record PublishEmailViaImapCommand : IRequest<Guid>
{
public required GetEmailAccountQuery Sender { get; init; }
public required IEnumerable<string> Recipients { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public bool IsHtml { get; init; } = true;
/// <summary>
/// IMAP folder to which the sent message will be appended (default: "Sent").
/// </summary>
public string SentFolder { get; init; } = "Sent";
[JsonIgnore]
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
public PublishEmailViaImapCommand WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
=> this with { Attachments = attachments };
}
public class PublishEmailViaImapCommandHandler(
IRepository<EmailAccount> Repo,
ISendingEmailPublisher Publisher,
IMapper Mapper,
ILogger<PublishEmailViaImapCommandHandler> Logger) : IRequestHandler<PublishEmailViaImapCommand, Guid>
{
public async Task<Guid> Handle(PublishEmailViaImapCommand request, CancellationToken cancellationToken)
{
var senderAccounts = await Repo.FindAsync(
request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username,
cancellationToken: cancellationToken);
if (senderAccounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
var senderAccount = senderAccounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
if (string.IsNullOrWhiteSpace(senderAccount.ImapServer))
throw new BadRequestException(
$"IMAP is not configured for account '{senderAccount.Username}' (Id: {senderAccount.Id}). Set ImapServer in EmailAccounts configuration.");
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
var sendingEmailEvent = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = emailContext,
QueuedAt = DateTime.Now,
SentFolder = request.SentFolder,
UseImapAppend = true
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
#endif

View File

@@ -0,0 +1,79 @@
#if NET
using AutoMapper;
using DigitalData.MessagingService.Application.Common.Dto;
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;
using System.Text.Json.Serialization;
namespace DigitalData.MessagingService.Application.EmailSending.Commands;
/// <summary>
/// Command to send an email via SMTP using OAuth2 authentication (queued via RabbitMQ).
/// The account must have <c>UseOAuth2 = true</c> and valid OAuth2 credentials configured.
/// </summary>
public record PublishEmailViaOAuth2Command : IRequest<Guid>
{
public required GetEmailAccountQuery Sender { get; init; }
public required IEnumerable<string> Recipients { get; init; }
public required string Subject { get; init; }
public required string Body { get; init; }
public bool IsHtml { get; init; } = true;
[JsonIgnore]
internal IEnumerable<EmailAttachmentDto> Attachments { get; private init; } = [];
public PublishEmailViaOAuth2Command WithAttachments(IEnumerable<EmailAttachmentDto> attachments)
=> this with { Attachments = attachments };
}
public class PublishEmailViaOAuth2CommandHandler(
IRepository<EmailAccount> Repo,
ISendingEmailPublisher Publisher,
IMapper Mapper,
ILogger<PublishEmailViaOAuth2CommandHandler> Logger) : IRequestHandler<PublishEmailViaOAuth2Command, Guid>
{
public async Task<Guid> Handle(PublishEmailViaOAuth2Command request, CancellationToken cancellationToken)
{
var senderAccounts = await Repo.FindAsync(
request.Sender.Id is int id ? x => x.Id == id : x => x.Username == request.Sender.Username,
cancellationToken: cancellationToken);
if (senderAccounts.Count() > 1)
Logger.LogWarning("Multiple email accounts found ({Criteria}). Using first.",
request.Sender.Id is not null ? $"Id: {request.Sender.Id}" : $"Username: {request.Sender.Username}");
var senderAccount = senderAccounts.FirstOrDefault()
?? throw new NotFoundException($"No email account found (Id: {request.Sender.Id}, Username: {request.Sender.Username}).");
if (!senderAccount.UseOAuth2)
throw new BadRequestException(
$"Account '{senderAccount.Username}' (Id: {senderAccount.Id}) is not configured for OAuth2. Set UseOAuth2 = true.");
if (string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientId) ||
string.IsNullOrWhiteSpace(senderAccount.OAuth2ClientSecret))
throw new BadRequestException(
$"OAuth2 credentials (ClientId, ClientSecret) are not configured for account '{senderAccount.Username}'.");
var emailContext = Mapper.Map<EmailContext>(request) with { Sender = senderAccount };
var sendingEmailEvent = new SendingEmailEvent
{
Id = Guid.NewGuid(),
Mail = emailContext,
QueuedAt = DateTime.Now
};
await Publisher.EnqueueAsync(sendingEmailEvent, cancellationToken);
return sendingEmailEvent.Id;
}
}
#endif