diff --git a/src/core/DigitalData.MessagingService.Abstraction/EmailAccountDto.cs b/src/core/DigitalData.MessagingService.Abstraction/EmailAccountDto.cs index 4ad1208..de47b68 100644 --- a/src/core/DigitalData.MessagingService.Abstraction/EmailAccountDto.cs +++ b/src/core/DigitalData.MessagingService.Abstraction/EmailAccountDto.cs @@ -35,4 +35,20 @@ public class EmailAccountDto public bool SmtpUseSsl { get; set; } public bool UseOAuth2 { get; set; } + + /// + /// IMAP server hostname (e.g. "imap.example.com"). + /// Leave empty when this account is send-only. + /// + public string? ImapServer { get; set; } + + /// + /// IMAP server port (993 for SSL, 143 for plain/STARTTLS). + /// + public int ImapPort { get; set; } = 993; + + /// + /// Use SSL/TLS when connecting to the IMAP server. + /// + public bool ImapUseSsl { get; set; } = true; } \ No newline at end of file diff --git a/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs b/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs new file mode 100644 index 0000000..022b3de --- /dev/null +++ b/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs @@ -0,0 +1,97 @@ +namespace DigitalData.MessagingService.Abstraction; + +/// +/// Represents an email message received via IMAP. +/// +public sealed class ReceivedEmailContext +{ + /// + /// Unique identifier of the message on the IMAP server (UID). + /// +#if NET + public long Uid { get; init; } +#else + public long Uid { get; set; } +#endif + + /// + /// Sender address (From header). + /// +#if NET + public string From { get; init; } = string.Empty; +#else + public string From { get; set; } = string.Empty; +#endif + + /// + /// Recipient addresses (To header). + /// +#if NET + public IEnumerable To { get; init; } = []; +#else + public IEnumerable To { get; set; } = []; +#endif + + /// + /// CC addresses. + /// +#if NET + public IEnumerable Cc { get; init; } = []; +#else + public IEnumerable Cc { get; set; } = []; +#endif + + /// + /// Email subject. + /// +#if NET + public string Subject { get; init; } = string.Empty; +#else + public string Subject { get; set; } = string.Empty; +#endif + + /// + /// Plain-text body (may be empty when only HTML is present). + /// +#if NET + public string TextBody { get; init; } = string.Empty; +#else + public string TextBody { get; set; } = string.Empty; +#endif + + /// + /// HTML body (may be empty when only plain-text is present). + /// +#if NET + public string HtmlBody { get; init; } = string.Empty; +#else + public string HtmlBody { get; set; } = string.Empty; +#endif + + /// + /// Date/time the message was sent (Date header). + /// +#if NET + public DateTime Date { get; init; } +#else + public DateTime Date { get; set; } +#endif + + /// + /// Attachments included with this message. + /// +#if NET + public IEnumerable Attachments { get; init; } = []; +#else + public IEnumerable Attachments { get; set; } = []; +#endif + + /// + /// Whether the message has been marked as seen/read on the server. + /// +#if NET + public bool IsSeen { get; init; } +#else + public bool IsSeen { get; set; } +#endif +} diff --git a/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs new file mode 100644 index 0000000..f1f5f36 --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/Common/Interfaces/IImapEmailService.cs @@ -0,0 +1,33 @@ +using DigitalData.MessagingService.Abstraction; + +namespace DigitalData.MessagingService.Application.Common.Interfaces; + +/// +/// Service interface for reading emails via IMAP. +/// +public interface IImapEmailService +{ + /// + /// Fetches emails from the specified mailbox folder. + /// + /// Account whose IMAP settings will be used. + /// Mailbox folder name (e.g. "INBOX"). Defaults to INBOX. + /// When returns only unread messages. + /// Maximum number of messages to retrieve (most-recent first). 0 = unlimited. + /// Cancellation token. + Task> FetchEmailsAsync( + EmailAccountDto account, + string folder = "INBOX", + bool unseenOnly = false, + int maxCount = 50, + CancellationToken cancellationToken = default); + + /// + /// Marks a message as seen (read) on the server. + /// + Task MarkAsSeenAsync( + EmailAccountDto account, + long uid, + string folder = "INBOX", + CancellationToken cancellationToken = default); +} diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Commands/MarkEmailAsSeenCommand.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Commands/MarkEmailAsSeenCommand.cs new file mode 100644 index 0000000..d5d4ee4 --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Commands/MarkEmailAsSeenCommand.cs @@ -0,0 +1,42 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Application.EmailAccount.Queries; +using DigitalData.MessagingService.Domain.Exceptions; +using MediatR; + +namespace DigitalData.MessagingService.Application.EmailReceiving.Commands; + +/// +/// Command to mark a single IMAP message as seen (read). +/// +public record MarkEmailAsSeenCommand : IRequest +{ + public required GetSenderQuery Account { get; init; } + + /// + /// UID of the message to mark as seen. + /// + public required long Uid { get; init; } + + /// + /// Mailbox folder the message resides in (default: "INBOX"). + /// + public string Folder { get; init; } = "INBOX"; +} + +public class MarkEmailAsSeenCommandHandler( + ISender Sender, + IImapEmailService ImapService) : IRequestHandler +{ + public async Task Handle(MarkEmailAsSeenCommand request, CancellationToken cancellationToken) + { + var account = await Sender.Send(request.Account, cancellationToken) + ?? 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 InvalidOperationException( + $"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration."); + + await ImapService.MarkAsSeenAsync(account, request.Uid, request.Folder, cancellationToken); + } +} diff --git a/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs new file mode 100644 index 0000000..0aaca2a --- /dev/null +++ b/src/core/DigitalData.MessagingService.Application/EmailReceiving/Queries/FetchEmailsQuery.cs @@ -0,0 +1,56 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Application.EmailAccount.Queries; +using DigitalData.MessagingService.Abstraction; +using DigitalData.MessagingService.Domain.Exceptions; +using MediatR; + +namespace DigitalData.MessagingService.Application.EmailReceiving.Queries; + +/// +/// Query to fetch emails from an IMAP mailbox. +/// +public record FetchEmailsQuery : IRequest> +{ + /// + /// Identifies the email account to use. + /// + public required GetSenderQuery Account { get; init; } + + /// + /// Mailbox folder to read from (default: "INBOX"). + /// + public string Folder { get; init; } = "INBOX"; + + /// + /// When returns only unread messages. + /// + public bool UnseenOnly { get; init; } = false; + + /// + /// Maximum number of messages to retrieve (most-recent first). 0 = unlimited. + /// + public int MaxCount { get; init; } = 50; +} + +public class FetchEmailsQueryHandler( + ISender Sender, + IImapEmailService ImapService) : IRequestHandler> +{ + public async Task> Handle(FetchEmailsQuery request, CancellationToken cancellationToken) + { + var account = await Sender.Send(request.Account, cancellationToken) + ?? 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 InvalidOperationException( + $"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration."); + + return await ImapService.FetchEmailsAsync( + account, + request.Folder, + request.UnseenOnly, + request.MaxCount, + cancellationToken); + } +} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs index f0a9d56..14d4a91 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs @@ -23,8 +23,11 @@ public static class DependencyInjection IConfiguration configuration) { // --- External Services --- - // Email Service (using Limilabs Mail.dll - Singleton for use in EmailSenderWorker) + // Email Service - SMTP outbound (Limilabs Mail.dll) services.AddSingleton(); + + // Email Service - IMAP inbound (Limilabs Mail.dll) + services.AddSingleton(); // PDF Processing Service (using DevExpress.Pdf) services.AddScoped(); diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Extensions/ImapExtensions.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Extensions/ImapExtensions.cs new file mode 100644 index 0000000..139ca2e --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Extensions/ImapExtensions.cs @@ -0,0 +1,16 @@ +using Limilabs.Client.IMAP; + +namespace DigitalData.MessagingService.Infrastructure.Services.Extensions; + +public static class ImapExtensions +{ + public static async Task CloseSafelyAsync(this Imap imap) + { + try + { + if (imap.Connected) + await imap.CloseAsync(); + } + catch { /* Ignore disconnect errors */ } + } +} diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs new file mode 100644 index 0000000..a9f7e7c --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs @@ -0,0 +1,179 @@ +using System.Text; +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Abstraction; +using DigitalData.MessagingService.Domain.Exceptions; +using DigitalData.MessagingService.Infrastructure.Services.Extensions; +using Limilabs.Client.IMAP; +using Limilabs.Mail; +using Microsoft.Extensions.Logging; + +namespace DigitalData.MessagingService.Infrastructure.Services; + +/// +/// IMAP email service using Limilabs Mail.dll. +/// Opens a fresh connection per call — stateless and thread-safe. +/// +public class LimilabsImapEmailService( + IEncryptionService encryptionService, + ILogger logger) : IImapEmailService +{ + static LimilabsImapEmailService() + { + Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + } + + // Public API + public async Task> FetchEmailsAsync( + EmailAccountDto account, + string folder = "INBOX", + bool unseenOnly = false, + int maxCount = 50, + CancellationToken cancellationToken = default) + { + using var imap = new Imap(); + try + { + await ConnectAndAuthenticateAsync(imap, account); + await SelectFolderAsync(imap, folder); + + // Get UIDs to fetch + List uids = unseenOnly + ? [.. await imap.SearchAsync(Flag.Unseen, cancellationToken)] + : [.. await imap.GetAllAsync(cancellationToken)]; + + // Most-recent first; honour maxCount + uids.Reverse(); + if (maxCount > 0 && uids.Count > maxCount) + uids = uids.Take(maxCount).ToList(); + + var results = new List(uids.Count); + + foreach (var uid in uids) + { + cancellationToken.ThrowIfCancellationRequested(); + + try + { + var eml = await imap.PeekMessageByUIDAsync(uid, cancellationToken); + var mail = new MailBuilder().CreateFromEml(eml); + var flags = await imap.GetFlagsByUIDAsync(uid, cancellationToken); + + results.Add(MapToContext(uid, mail, flags)); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.", uid, folder); + } + } + + await imap.CloseAsync(cancellationToken); + return results; + } + catch (Limilabs.Client.ServerException ex) + { + await imap.CloseSafelyAsync(); + throw new AuthenticationFailedException( + $"IMAP authentication failed for account '{account.Username}'.", ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await imap.CloseSafelyAsync(); + throw new InvalidOperationException( + $"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex); + } + } + + public async Task MarkAsSeenAsync( + EmailAccountDto account, + long uid, + string folder = "INBOX", + CancellationToken cancellationToken = default) + { + using var imap = new Imap(); + try + { + await ConnectAndAuthenticateAsync(imap, account); + await SelectFolderAsync(imap, folder); + await imap.MarkMessageSeenByUIDAsync(uid, cancellationToken); + await imap.CloseAsync(cancellationToken); + } + catch (Limilabs.Client.ServerException ex) + { + await imap.CloseSafelyAsync(); + throw new AuthenticationFailedException( + $"IMAP authentication failed for account '{account.Username}'.", ex); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + await imap.CloseSafelyAsync(); + throw new InvalidOperationException( + $"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex); + } + } + + // Private helpers + private async Task ConnectAndAuthenticateAsync(Imap imap, EmailAccountDto account) + { + if (account.ImapUseSsl) + await imap.ConnectSSLAsync(account.ImapServer!, account.ImapPort); + else + await imap.ConnectAsync(account.ImapServer!, account.ImapPort); + + var password = account.PasswordEncrypted + ? encryptionService.Decrypt(account.Password) + : account.Password; + + await imap.LoginAsync(account.Username, password); + } + + private static async Task SelectFolderAsync(Imap imap, string folder) + { + if (string.Equals(folder, "INBOX", StringComparison.OrdinalIgnoreCase)) + await imap.SelectInboxAsync(); + else + await imap.SelectAsync(folder); + } + + private static ReceivedEmailContext MapToContext(long uid, IMail mail, List flags) + { + var attachments = new List(); + + foreach (var att in mail.Attachments) + { + attachments.Add(new EmailAttachmentContext + { + FileName = att.FileName ?? "attachment", + Content = att.Data, + ContentType = att.ContentType?.ToString() ?? "application/octet-stream", + IsInline = false, + ContentId = att.ContentId + }); + } + + foreach (var vis in mail.Visuals) + { + attachments.Add(new EmailAttachmentContext + { + FileName = vis.FileName ?? "inline", + Content = vis.Data, + ContentType = vis.ContentType?.ToString() ?? "application/octet-stream", + IsInline = true, + ContentId = vis.ContentId + }); + } + + return new ReceivedEmailContext + { + Uid = uid, + From = mail.From.FirstOrDefault()?.Address ?? string.Empty, + To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)], + Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)], + Subject = mail.Subject ?? string.Empty, + TextBody = mail.Text ?? string.Empty, + HtmlBody = mail.Html ?? string.Empty, + Date = mail.Date ?? DateTime.MinValue, + Attachments = attachments, + IsSeen = flags?.Contains(Flag.Seen) ?? false + }; + } +} diff --git a/src/presentation/DigitalData.MessagingService.API/Controllers/EmailsController.cs b/src/presentation/DigitalData.MessagingService.API/Controllers/EmailsController.cs index 399d9e7..af57a8e 100644 --- a/src/presentation/DigitalData.MessagingService.API/Controllers/EmailsController.cs +++ b/src/presentation/DigitalData.MessagingService.API/Controllers/EmailsController.cs @@ -1,4 +1,7 @@ using DigitalData.MessagingService.Application.EmailSending.Commands; +using DigitalData.MessagingService.Application.EmailReceiving.Queries; +using DigitalData.MessagingService.Application.EmailReceiving.Commands; +using DigitalData.MessagingService.Application.EmailAccount.Queries; using DigitalData.MessagingService.Abstraction; using MediatR; using Microsoft.AspNetCore.Mvc; @@ -13,6 +16,7 @@ namespace DigitalData.MessagingService.API.Controllers; [Route("api/[controller]")] public class EmailsController(IMediator mediator) : ControllerBase { + #region Send /// /// Send an email, optionally with file attachments. /// Omit the attachments field for a plain send. @@ -61,4 +65,68 @@ public class EmailsController(IMediator mediator) : ControllerBase return result; } + #endregion Send + + #region Receive + /// + /// Fetch emails from an IMAP mailbox. + /// + /// Id of the email account (must have ImapServer configured). + /// Mailbox folder to read (default: INBOX). + /// Return only unread messages. + /// Maximum number of messages to return (most-recent first, default: 50). + /// Cancellation token. + /// HTTP 200 with list of received emails. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task FetchEmails( + [FromQuery] int accountId, + [FromQuery] string folder = "INBOX", + [FromQuery] bool unseenOnly = false, + [FromQuery] int maxCount = 50, + CancellationToken cancellationToken = default) + { + var query = new FetchEmailsQuery + { + Account = new GetSenderQuery { Id = accountId }, + Folder = folder, + UnseenOnly = unseenOnly, + MaxCount = maxCount + }; + + var emails = await mediator.Send(query, cancellationToken); + return Ok(emails); + } + + /// + /// Mark a single IMAP message as seen (read). + /// + /// Id of the email account. + /// UID of the message on the IMAP server. + /// Mailbox folder the message resides in (default: INBOX). + /// Cancellation token. + /// HTTP 204 No Content. + [HttpPatch("{uid}/seen")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + public async Task MarkAsSeen( + [FromRoute] long uid, + [FromQuery] int accountId, + [FromQuery] string folder = "INBOX", + CancellationToken cancellationToken = default) + { + var command = new MarkEmailAsSeenCommand + { + Account = new GetSenderQuery { Id = accountId }, + Uid = uid, + Folder = folder + }; + + await mediator.Send(command, cancellationToken); + return NoContent(); + } + #endregion Receive }