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:
@@ -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).
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -50,6 +50,9 @@ public static class DependencyInjection
|
||||
|
||||
// Register Background Workers
|
||||
services.AddHostedService<AsyncInitWorker>();
|
||||
services.AddHostedService<EmailSyncWorker>();
|
||||
|
||||
services.AddMemoryCache();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user