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>
|
/// <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).
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 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);
|
||||||
|
|||||||
Reference in New Issue
Block a user