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>
/// Represents an email message received via IMAP.
/// </summary>
public sealed class ReceivedEmailContext
public sealed record ReceivedEmailContext
{
/// <summary>
/// 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.
/// </summary>
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
services.AddHostedService<AsyncInitWorker>();
services.AddHostedService<EmailSyncWorker>();
services.AddMemoryCache();
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 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.
/// </summary>
public class LimilabsImapEmailService(
IEncryptionService encryptionService,
ILogger<LimilabsImapEmailService> logger) : IImapEmailService
IEncryptionService EncryptionService,
ILogger<LimilabsImapEmailService> 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<ReceivedEmailContext?> FetchEmailByUidAsync(
private async Task<ReceivedEmailContext?> 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<EmailAttachmentContext>();
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<EmailAttachmentContext>();
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);