From f7e95eb7f7186987659020fe62d1b2555e05d1e7 Mon Sep 17 00:00:00 2001 From: TekH Date: Mon, 17 Aug 2026 10:14:59 +0200 Subject: [PATCH] feat(application): add PublishEmailViaImap/OAuth2 commands and ReadEmailViaPop3/OAuth2 queries with handlers --- .../Queries/ReadEmailViaOAuth2Query.cs | 77 ++++++++++++++++++ .../Queries/ReadEmailViaPop3Query.cs | 74 +++++++++++++++++ .../Commands/PublishEmailViaImapCommand.cs | 81 +++++++++++++++++++ .../Commands/PublishEmailViaOAuth2Command.cs | 79 ++++++++++++++++++ 4 files changed, 311 insertions(+) create mode 100644 src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaOAuth2Query.cs create mode 100644 src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaPop3Query.cs create mode 100644 src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaImapCommand.cs create mode 100644 src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaOAuth2Command.cs diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaOAuth2Query.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaOAuth2Query.cs new file mode 100644 index 0000000..51bf42c --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaOAuth2Query.cs @@ -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; + +/// +/// Query to fetch emails from an IMAP mailbox using OAuth2 authentication. +/// The account must have UseOAuth2 = true and valid OAuth2 credentials configured. +/// +public record ReadEmailViaOAuth2Query : IRequest +{ + /// + /// Identifies the email account to use. + /// + public required GetEmailAccountQuery Account { get; init; } + + /// + /// Mail query used to filter and limit the emails retrieved. + /// + public MailSearchFilter Mail { get; init; } = new(); +} + +public class ReadEmailViaOAuth2QueryHandler( + IMapper Mapper, + ILogger Logger, + IRepository EmailAccountRepo, + IReceivedEmailRepository MailRepo, + IImapEmailService imapEmailService) : IRequestHandler +{ + public async Task 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>(mails) + }; + } +} +#endif diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaPop3Query.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaPop3Query.cs new file mode 100644 index 0000000..0f3bee0 --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/ReadEmailViaPop3Query.cs @@ -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; + +/// +/// Query to fetch emails from a POP3 mailbox. +/// Triggers an on-demand sync and returns stored results filtered by . +/// +public record ReadEmailViaPop3Query : IRequest +{ + /// + /// Identifies the email account to use. + /// + public required GetEmailAccountQuery Account { get; init; } + + /// + /// 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". + /// + public MailSearchFilter Mail { get; init; } = new(); +} + +public class ReadEmailViaPop3QueryHandler( + IMapper Mapper, + ILogger Logger, + IRepository EmailAccountRepo, + IReceivedEmailRepository MailRepo, + IPop3EmailService pop3EmailService) : IRequestHandler +{ + public async Task 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>(mails) + }; + } +} +#endif diff --git a/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaImapCommand.cs b/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaImapCommand.cs new file mode 100644 index 0000000..8d5e391 --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaImapCommand.cs @@ -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; + +/// +/// 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. +/// +public record PublishEmailViaImapCommand : IRequest +{ + public required GetEmailAccountQuery Sender { get; init; } + + public required IEnumerable Recipients { get; init; } + + public required string Subject { get; init; } + + public required string Body { get; init; } + + public bool IsHtml { get; init; } = true; + + /// + /// IMAP folder to which the sent message will be appended (default: "Sent"). + /// + public string SentFolder { get; init; } = "Sent"; + + [JsonIgnore] + internal IEnumerable Attachments { get; private init; } = []; + + public PublishEmailViaImapCommand WithAttachments(IEnumerable attachments) + => this with { Attachments = attachments }; +} + +public class PublishEmailViaImapCommandHandler( + IRepository Repo, + ISendingEmailPublisher Publisher, + IMapper Mapper, + ILogger Logger) : IRequestHandler +{ + public async Task 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(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 diff --git a/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaOAuth2Command.cs b/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaOAuth2Command.cs new file mode 100644 index 0000000..4804dcd --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailSending/Commands/PublishEmailViaOAuth2Command.cs @@ -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; + +/// +/// Command to send an email via SMTP using OAuth2 authentication (queued via RabbitMQ). +/// The account must have UseOAuth2 = true and valid OAuth2 credentials configured. +/// +public record PublishEmailViaOAuth2Command : IRequest +{ + public required GetEmailAccountQuery Sender { get; init; } + + public required IEnumerable Recipients { get; init; } + + public required string Subject { get; init; } + + public required string Body { get; init; } + + public bool IsHtml { get; init; } = true; + + [JsonIgnore] + internal IEnumerable Attachments { get; private init; } = []; + + public PublishEmailViaOAuth2Command WithAttachments(IEnumerable attachments) + => this with { Attachments = attachments }; +} + +public class PublishEmailViaOAuth2CommandHandler( + IRepository Repo, + ISendingEmailPublisher Publisher, + IMapper Mapper, + ILogger Logger) : IRequestHandler +{ + public async Task 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(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