Refactor IMAP service for repository integration

Replaced `FetchEmailsAsync` with `SyncEmailsAsync` in `IImapEmailService` to simplify email synchronization. Updated method signatures to use `DateFilter` and added default parameters for `folder` and `cancel`.

Refactored `LimilabsImapEmailService` to remove client-side filtering logic and delegate storage to `IRepository<ReceivedEmail>`. Simplified IMAP search criteria to focus on date-based filtering and added checks to skip already-synced emails.

Updated `EmailSyncWorker` to use the new `SyncEmailsAsync` method. Removed unused code, streamlined exception handling, and improved maintainability by reducing complexity and focusing on server-side filtering.
This commit is contained in:
2026-08-13 14:18:43 +02:00
parent 7207c5b4d9
commit 4964fa4344
3 changed files with 31 additions and 84 deletions

View File

@@ -14,15 +14,16 @@ public interface IImapEmailService
/// Fetches emails from the specified mailbox folder. /// Fetches emails from the specified mailbox folder.
/// </summary> /// </summary>
/// <param name="account">Account whose IMAP settings will be used.</param> /// <param name="account">Account whose IMAP settings will be used.</param>
/// <param name="filter">Filter to apply when fetching emails.</param> /// <param name="date"></param>
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server. /// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally). /// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally).
/// </param> /// </param>
/// <param name="cancellationToken">Cancellation token.</param> /// <param name="cancellationToken">Cancellation token.</param>
Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync( public Task SyncEmailsAsync(
EmailAccount account, EmailAccount account,
MailSearchFilter filter, DateFilter date,
CancellationToken cancellationToken = default); string folder = "INBOX",
CancellationToken cancel = default);
/// <summary> /// <summary>
/// Marks a message as seen (read) on the server. /// Marks a message as seen (read) on the server.

View File

@@ -47,7 +47,7 @@ public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccoun
Before = DateTime.UtcNow Before = DateTime.UtcNow
}; };
await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken); await limapService.SyncEmailsAsync(account, _dateFilter, cancel: stoppingToken);
} }
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing); await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);

View File

@@ -1,11 +1,13 @@
using DigitalData.MessagingService.Application.Common.Dto; using DigitalData.MessagingService.Application.Common.Dto;
using DigitalData.MessagingService.Application.Common.Dto.MailSearch; using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
using DigitalData.MessagingService.Application.Common.Interfaces; using DigitalData.MessagingService.Application.Common.Interfaces;
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
using DigitalData.MessagingService.Domain.Entities; using DigitalData.MessagingService.Domain.Entities;
using DigitalData.MessagingService.Domain.Exceptions; 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.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using System.Text; using System.Text;
@@ -15,7 +17,7 @@ namespace DigitalData.MessagingService.Infrastructure.Services;
/// IMAP email service using Limilabs Mail.dll. /// IMAP email service using Limilabs Mail.dll.
/// 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(ILogger<LimilabsImapEmailService> Logger) : IImapEmailService public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IRepository<ReceivedEmail> Repository) : IImapEmailService
{ {
static LimilabsImapEmailService() static LimilabsImapEmailService()
{ {
@@ -23,42 +25,40 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger)
} }
// Public API // Public API
public async Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync( public async Task SyncEmailsAsync(
EmailAccount account, EmailAccount account,
MailSearchFilter filter, DateFilter date,
string folder = "INBOX",
CancellationToken cancel = default) CancellationToken cancel = default)
{ {
using var imap = await OpenAsync(account, filter.Folder, cancel); using var imap = await OpenAsync(account, folder, cancel);
try try
{ {
#region Find UIDs #region Find UIDs
// Server-side: only date range; all other filters are applied in-process after cache retrieval // Server-side: only date range; all other filters are applied in-process after cache retrieval
List<ICriterion> criterions = []; List<ICriterion> criterions = [];
if (filter.Date is DateFilter dateF) if (date.After is DateTime after)
{ criterions.Add(Expression.SentSince(after.Date));
if (dateF.After is DateTime after)
criterions.Add(Expression.SentSince(after.Date));
// IMAP BEFORE is exclusive, so add one day to make the bound inclusive // IMAP BEFORE is exclusive, so add one day to make the bound inclusive
if (dateF.Before is DateTime before) if (date.Before is DateTime before)
criterions.Add(Expression.SentBefore(before.Date.AddDays(1))); criterions.Add(Expression.SentBefore(before.Date.AddDays(1)));
}
var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All(); var searchExpression = criterions.Count > 0 ? Expression.And([.. criterions]) : Expression.All();
List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)]; List<long> uids = [.. await imap.SearchAsync(searchExpression, cancel)];
if (filter.SortOrder == MailSortOrder.NewestFirst)
uids.Reverse();
#endregion #endregion
if (uids.Count == 0) if (uids.Count == 0)
return []; return;
var results = new List<ReceivedEmailDto>(uids.Count); var emails = new List<ReceivedEmailDto>(uids.Count);
foreach (var uid in uids) foreach (var uid in uids)
{ {
if (await Repository.AnyAsync(x => x.Uid == uid, cancel))
continue;
cancel.ThrowIfCancellationRequested(); cancel.ThrowIfCancellationRequested();
try try
@@ -109,71 +109,24 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger)
}; };
#endregion Read email #endregion Read email
if (filter.UnseenOnly && email.IsSeen) emails.Add(email);
continue;
if (filter.SubjectContains is string subject &&
!email.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.SenderContains is string sender &&
!email.From.Contains(sender, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.RecipientContains is string recipient &&
!email.To.Any(t => t.Contains(recipient, StringComparison.OrdinalIgnoreCase)) &&
!email.Cc.Any(c => c.Contains(recipient, StringComparison.OrdinalIgnoreCase)))
continue;
if (filter.BodyContains is string body &&
!email.TextBody.Contains(body, StringComparison.OrdinalIgnoreCase) &&
!email.HtmlBody.Contains(body, StringComparison.OrdinalIgnoreCase))
continue;
if (filter.Uid is UidFilter uidF)
{
if (uidF.Absolute is long exactUid && email.Uid != exactUid)
continue;
if (uidF.Min is long min && email.Uid < min)
continue;
if (uidF.Max is long max && email.Uid > max)
continue;
}
if (filter.WithAttachments)
results.Add(email);
else
results.Add(email with { Attachments = [] });
} }
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, folder);
} }
} }
await imap.CloseAsync(cancel); await imap.CloseAsync(cancel);
if (filter.MaxCount is int maxCount && maxCount > 0 && results.Count > maxCount) await Repository.CreateAsync(emails, cancel);
return results.Take(maxCount);
return results;
} }
catch (Limilabs.Client.ServerException ex) catch
{ {
await imap.CloseSafelyAsync(); await imap.CloseSafelyAsync();
throw new AuthenticationFailedException( throw;
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to fetch emails from IMAP server '{account.ImapServer}'.", ex);
} }
} }
@@ -189,21 +142,14 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger)
await imap.MarkMessageSeenByUIDAsync(uid, cancel); await imap.MarkMessageSeenByUIDAsync(uid, cancel);
await imap.CloseAsync(cancel); await imap.CloseAsync(cancel);
} }
catch (Limilabs.Client.ServerException ex) catch
{ {
await imap.CloseSafelyAsync(); await imap.CloseSafelyAsync();
throw new AuthenticationFailedException( throw;
$"IMAP authentication failed for account '{account.Username}'.", ex);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
await imap.CloseSafelyAsync();
throw new InvalidOperationException(
$"Failed to mark message UID={uid} as seen on '{account.ImapServer}'.", ex);
} }
} }
private static async Task<Imap> OpenAsync(EmailAccount account, string folder, CancellationToken cancel) private static async Task<Imap> OpenAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
{ {
var imap = new Imap(); var imap = new Imap();