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.
This commit is contained in:
2026-08-12 11:48:29 +02:00
parent f521683608
commit a446162afa
5 changed files with 112 additions and 48 deletions

View File

@@ -3,7 +3,7 @@ namespace DigitalData.MessagingService.Abstraction;
/// <summary> /// <summary>
/// Represents an email message received via IMAP. /// Represents an email message received via IMAP.
/// </summary> /// </summary>
public sealed class ReceivedEmailContext public sealed record ReceivedEmailContext
{ {
/// <summary> /// <summary>
/// Unique identifier of the message on the IMAP server (UID). /// Unique identifier of the message on the IMAP server (UID).

View File

@@ -14,4 +14,10 @@ public class EmailAccountsOptions
/// The list of configured email accounts. /// The list of configured email accounts.
/// </summary> /// </summary>
public required IEnumerable<EmailAccountDto> Accounts { get; init; } = []; public required IEnumerable<EmailAccountDto> Accounts { get; init; } = [];
/// <summary>
/// How often the IMAP sync worker polls for new emails, in seconds.
/// Defaults to 300 seconds (5 minutes).
/// </summary>
public int SyncIntervalSeconds { get; init; } = 300;
} }

View File

@@ -50,6 +50,9 @@ public static class DependencyInjection
// Register Background Workers // Register Background Workers
services.AddHostedService<AsyncInitWorker>(); services.AddHostedService<AsyncInitWorker>();
services.AddHostedService<EmailSyncWorker>();
services.AddMemoryCache();
return services; return services;
} }

View File

@@ -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<EmailAccountsOptions> 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);
}
}
}

View File

@@ -5,6 +5,7 @@ using DigitalData.MessagingService.Domain.Exceptions;
using DigitalData.MessagingService.Infrastructure.Services.Extensions; using DigitalData.MessagingService.Infrastructure.Services.Extensions;
using Limilabs.Client.IMAP; using Limilabs.Client.IMAP;
using Limilabs.Mail; using Limilabs.Mail;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Text; using System.Text;
@@ -15,9 +16,12 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// Opens a fresh connection per call — stateless and thread-safe. /// Opens a fresh connection per call — stateless and thread-safe.
/// </summary> /// </summary>
public class LimilabsImapEmailService( public class LimilabsImapEmailService(
IEncryptionService encryptionService, IEncryptionService EncryptionService,
ILogger<LimilabsImapEmailService> logger) : IImapEmailService ILogger<LimilabsImapEmailService> Logger,
IMemoryCache Cache) : IImapEmailService
{ {
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
static LimilabsImapEmailService() static LimilabsImapEmailService()
{ {
Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); Encoding.RegisterProvider(CodePagesEncodingProvider.Instance);
@@ -52,7 +56,7 @@ public class LimilabsImapEmailService(
} }
catch (Exception ex) catch (Exception ex)
{ {
logger.LogWarning(ex, Logger.LogWarning(ex,
"Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.", "Failed to fetch IMAP message UID={Uid} from folder {Folder}. Skipping.",
uid, filter.Folder); uid, filter.Folder);
} }
@@ -132,7 +136,7 @@ public class LimilabsImapEmailService(
catch (Exception ex) when (ex is not OperationCanceledException) catch (Exception ex) when (ex is not OperationCanceledException)
{ {
await imap.CloseSafelyAsync(); 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; return null;
} }
} }
@@ -226,56 +230,60 @@ public class LimilabsImapEmailService(
return uids; return uids;
} }
private static async Task<ReceivedEmailContext?> FetchEmailByUidAsync( private async Task<ReceivedEmailContext?> FetchEmailByUidAsync(
Imap imap, Imap imap,
long uid, long uid,
bool withAttachments = false, bool withAttachments = false,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken); var email = await Cache.GetOrCreateAsync(
var mail = new MailBuilder().CreateFromEml(eml); CacheKeyPrefix + uid,
async entry =>
var attachments = new List<EmailAttachmentContext>();
if (withAttachments)
{
foreach (var att in mail.Attachments)
{ {
attachments.Add(new EmailAttachmentContext var eml = await imap.GetMessageByUIDAsync(uid, cancellationToken);
{ var mail = new MailBuilder().CreateFromEml(eml);
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) var attachments = new List<EmailAttachmentContext>();
{
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 foreach (var att in mail.Attachments)
{ {
Uid = uid, attachments.Add(new EmailAttachmentContext
From = mail.From.FirstOrDefault()?.Address ?? string.Empty, {
To = [.. mail.To.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)], FileName = att.FileName ?? "attachment",
Cc = [.. mail.Cc.SelectMany(m => m.GetMailboxes()).Select(mb => mb.Address)], Content = att.Data,
Subject = mail.Subject ?? string.Empty, ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
TextBody = mail.Text ?? string.Empty, IsInline = false,
HtmlBody = mail.Html ?? string.Empty, ContentId = att.ContentId
Date = mail.Date ?? DateTime.MinValue, });
Attachments = attachments, }
};
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) private async Task ConnectAndAuthenticateAsync(Imap imap, EmailAccountDto account)
@@ -286,7 +294,7 @@ public class LimilabsImapEmailService(
await imap.ConnectAsync(account.ImapServer!, account.ImapPort); await imap.ConnectAsync(account.ImapServer!, account.ImapPort);
var password = account.PasswordEncrypted var password = account.PasswordEncrypted
? encryptionService.Decrypt(account.Password) ? EncryptionService.Decrypt(account.Password)
: account.Password; : account.Password;
await imap.LoginAsync(account.Username, password); await imap.LoginAsync(account.Username, password);