From a446162afaaf7586f1cfb1344ba4a30810c1c8f2 Mon Sep 17 00:00:00 2001 From: TekH Date: Wed, 12 Aug 2026 11:48:29 +0200 Subject: [PATCH] Add EmailSyncWorker and enhance IMAP service with caching Refactored `ReceivedEmailContext` to use `record` for immutability and value-based equality. Added `SyncIntervalSeconds` to `EmailAccountsOptions` for configuring IMAP sync intervals. Introduced `EmailSyncWorker` as a background service for periodic email synchronization. Registered `IMemoryCache` and integrated caching in `LimilabsImapEmailService` to reduce redundant fetch operations. Optimized `FetchEmailByUidAsync` to conditionally handle attachments and improve performance. Refactored logging and improved code readability by adopting modern C# features like `record`, `with` expressions, and `IOptions`. Performed general cleanup and streamlined method implementations. --- .../ReceivedEmailContext.cs | 2 +- .../Common/Options/EmailAccountsOptions.cs | 6 ++ .../DependencyInjection.cs | 3 + .../Services/Background/EmailSyncWorker.cs | 47 ++++++++ .../Services/LimilabsImapEmailService.cs | 102 ++++++++++-------- 5 files changed, 112 insertions(+), 48 deletions(-) create mode 100644 src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Background/EmailSyncWorker.cs diff --git a/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs b/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs index 022b3de..83be50f 100644 --- a/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs +++ b/src/core/DigitalData.MessagingService.Abstraction/ReceivedEmailContext.cs @@ -3,7 +3,7 @@ namespace DigitalData.MessagingService.Abstraction; /// /// Represents an email message received via IMAP. /// -public sealed class ReceivedEmailContext +public sealed record ReceivedEmailContext { /// /// Unique identifier of the message on the IMAP server (UID). diff --git a/src/core/DigitalData.MessagingService.Application/Common/Options/EmailAccountsOptions.cs b/src/core/DigitalData.MessagingService.Application/Common/Options/EmailAccountsOptions.cs index 763f430..97e451e 100644 --- a/src/core/DigitalData.MessagingService.Application/Common/Options/EmailAccountsOptions.cs +++ b/src/core/DigitalData.MessagingService.Application/Common/Options/EmailAccountsOptions.cs @@ -14,4 +14,10 @@ public class EmailAccountsOptions /// The list of configured email accounts. /// public required IEnumerable Accounts { get; init; } = []; + + /// + /// How often the IMAP sync worker polls for new emails, in seconds. + /// Defaults to 300 seconds (5 minutes). + /// + public int SyncIntervalSeconds { get; init; } = 300; } diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs index f60b874..5c18aad 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/DependencyInjection.cs @@ -50,6 +50,9 @@ public static class DependencyInjection // Register Background Workers services.AddHostedService(); + services.AddHostedService(); + + services.AddMemoryCache(); return services; } diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Background/EmailSyncWorker.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Background/EmailSyncWorker.cs new file mode 100644 index 0000000..b1e2430 --- /dev/null +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/Background/EmailSyncWorker.cs @@ -0,0 +1,47 @@ +using DigitalData.MessagingService.Application.Common.Interfaces; +using DigitalData.MessagingService.Application.Common.Models.MailSearch; +using DigitalData.MessagingService.Application.Common.Options; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace DigitalData.MessagingService.Infrastructure.Services.Background; + +public class EmailSyncWorker(IImapEmailService imapService, IOptions Options) : BackgroundService +{ + private DateFilter? _dateFilter = null; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + if (imapService is not LimilabsImapEmailService limapService) + { + await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + return; + } + + var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds); + + while (!stoppingToken.IsCancellationRequested) + { + foreach (var account in Options.Value.Accounts) + if (account.ImapServer is not null) + { + // init or update last date filter + _dateFilter = _dateFilter is null + ? new DateFilter + { + After = null, + Before = DateTime.UtcNow + } + : new DateFilter + { + After = _dateFilter.Before, + Before = DateTime.UtcNow + }; + + await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken); + } + + await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); + } + } +} \ No newline at end of file diff --git a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs index 2a8ec86..4244b99 100644 --- a/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs +++ b/src/infrastructure/DigitalData.MessagingService.Infrastructure/Services/LimilabsImapEmailService.cs @@ -5,6 +5,7 @@ using DigitalData.MessagingService.Domain.Exceptions; using DigitalData.MessagingService.Infrastructure.Services.Extensions; using Limilabs.Client.IMAP; using Limilabs.Mail; +using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using System.Text; @@ -15,9 +16,12 @@ namespace DigitalData.MessagingService.Infrastructure.Services; /// Opens a fresh connection per call — stateless and thread-safe. /// public class LimilabsImapEmailService( - IEncryptionService encryptionService, - ILogger logger) : IImapEmailService + IEncryptionService EncryptionService, + ILogger Logger, + IMemoryCache Cache) : IImapEmailService { + private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString(); + static LimilabsImapEmailService() { Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); @@ -52,7 +56,7 @@ public class LimilabsImapEmailService( } catch (Exception ex) { - logger.LogWarning(ex, + Logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.", uid, filter.Folder); } @@ -132,7 +136,7 @@ public class LimilabsImapEmailService( catch (Exception ex) when (ex is not OperationCanceledException) { await imap.CloseSafelyAsync(); - logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}.", uid, folder); + Logger.LogWarning(ex, "Failed to fetch IMAP message UID={Uid} from folder {Folder}.", uid, folder); return null; } } @@ -226,56 +230,60 @@ public class LimilabsImapEmailService( return uids; } - private static async Task FetchEmailByUidAsync( + private async Task FetchEmailByUidAsync( Imap imap, long uid, bool withAttachments = false, CancellationToken cancellationToken = default) { - var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken); - var mail = new MailBuilder().CreateFromEml(eml); - - var attachments = new List(); - - if (withAttachments) - { - foreach (var att in mail.Attachments) + var email = await Cache.GetOrCreateAsync( + CacheKeyPrefix + uid, + async entry => { - attachments.Add(new EmailAttachmentContext - { - FileName = att.FileName ?? "attachment", - Content = att.Data, - ContentType = att.ContentType?.ToString() ?? "application/octet-stream", - IsInline = false, - ContentId = att.ContentId - }); - } + var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken); + var mail = new MailBuilder().CreateFromEml(eml); - 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 - }); - } - } + var attachments = new List(); - 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, - }; + 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, + }; + }); + + return withAttachments || email is null ? email : email with { Attachments = [] }; } private async Task ConnectAndAuthenticateAsync(Imap imap, EmailAccountDto account) @@ -286,7 +294,7 @@ public class LimilabsImapEmailService( await imap.ConnectAsync(account.ImapServer!, account.ImapPort); var password = account.PasswordEncrypted - ? encryptionService.Decrypt(account.Password) + ? EncryptionService.Decrypt(account.Password) : account.Password; await imap.LoginAsync(account.Username, password);