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
}