Compare commits
15 Commits
86a07e5017
...
d01a0ceaa6
| Author | SHA1 | Date | |
|---|---|---|---|
| d01a0ceaa6 | |||
| fd234618f7 | |||
| f99bd8c399 | |||
| c0bd391297 | |||
| cda70c8ced | |||
| 578ecc7ba1 | |||
| 6abe0e18f6 | |||
| c9b7d99ecc | |||
| 4964fa4344 | |||
| 7207c5b4d9 | |||
| 7f04c4b09f | |||
| 43d7c393bb | |||
| e73bead2f2 | |||
| 606ff94a77 | |||
| 1c9af0e560 |
1
legacy
Submodule
1
legacy
Submodule
Submodule legacy added at e59b936181
@@ -0,0 +1,5 @@
|
|||||||
|
#if NET
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Dto;
|
||||||
|
|
||||||
|
public record EmailSyncResult(int ProcessedCount = 0, int FailedCount = 0);
|
||||||
|
#endif
|
||||||
@@ -14,6 +14,15 @@ public sealed record ReceivedEmailDto
|
|||||||
public long Uid { get; set; }
|
public long Uid { get; set; }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// ID of the email account this message belongs to.
|
||||||
|
/// </summary>
|
||||||
|
#if NET
|
||||||
|
public int AccountId { get; init; }
|
||||||
|
#else
|
||||||
|
public int AccountId { get; set; }
|
||||||
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sender address (From header).
|
/// Sender address (From header).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -94,4 +103,10 @@ public sealed record ReceivedEmailDto
|
|||||||
#else
|
#else
|
||||||
public bool IsSeen { get; set; }
|
public bool IsSeen { get; set; }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
#if NET
|
#if NET
|
||||||
using DigitalData.MessagingService.Application.Common.Dto;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
|
||||||
using DigitalData.MessagingService.Domain.Entities;
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
||||||
@@ -11,18 +10,16 @@ namespace DigitalData.MessagingService.Application.Common.Interfaces;
|
|||||||
public interface IImapEmailService
|
public interface IImapEmailService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Fetches emails from the specified mailbox folder.
|
///
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="account">Account whose IMAP settings will be used.</param>
|
/// <param name="account"></param>
|
||||||
/// <param name="filter">Filter to apply when fetching emails.</param>
|
/// <param name="folder"></param>
|
||||||
/// When <see langword="true"/> (default), fetched messages are marked as <c>\Seen</c> on the server.
|
/// <param name="cancel"></param>
|
||||||
/// Set to <see langword="false"/> for a non-destructive read (uses <c>BODY.PEEK</c> internally).
|
/// <returns></returns>
|
||||||
/// </param>
|
Task<EmailSyncResult> SyncEmailsAsync(
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
EmailAccount account,
|
||||||
Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
|
string folder = "INBOX",
|
||||||
EmailAccount account,
|
CancellationToken cancel = default);
|
||||||
MailSearchFilter filter,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a message as seen (read) on the server.
|
/// Marks a message as seen (read) on the server.
|
||||||
@@ -32,5 +29,13 @@ public interface IImapEmailService
|
|||||||
long uid,
|
long uid,
|
||||||
string folder = "INBOX",
|
string folder = "INBOX",
|
||||||
CancellationToken cancellationToken = default);
|
CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Gets the last IMAP sync date for the specified account and folder.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="accountId"></param>
|
||||||
|
/// <param name="folder"></param>
|
||||||
|
/// <returns></returns>
|
||||||
|
DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX");
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#if NET
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
|
||||||
|
public interface IReceivedEmailRepository : IRepository<ReceivedEmail>
|
||||||
|
{
|
||||||
|
public Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
|
#endif
|
||||||
@@ -11,7 +11,7 @@ public interface IRepository<TEntity> where TEntity : class
|
|||||||
// CREATE
|
// CREATE
|
||||||
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<IEnumerable<TEntity>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
// READ
|
// READ
|
||||||
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default);
|
||||||
|
|||||||
@@ -23,6 +23,19 @@ public class EmailMappingProfile : Profile
|
|||||||
// EmailAccountDto -> EmailAccount
|
// EmailAccountDto -> EmailAccount
|
||||||
CreateMap<EmailAccount, EmailAccountDto>();
|
CreateMap<EmailAccount, EmailAccountDto>();
|
||||||
CreateMap<EmailAccountModificationDto, EmailAccount>();
|
CreateMap<EmailAccountModificationDto, EmailAccount>();
|
||||||
|
|
||||||
|
// ReceivedEmailDto <-> ReceivedEmail
|
||||||
|
CreateMap<ReceivedEmailDto, ReceivedEmail>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Account, opt => opt.Ignore());
|
||||||
|
CreateMap<ReceivedEmail, ReceivedEmailDto>();
|
||||||
|
|
||||||
|
// EmailAttachmentDto <-> EmailAttachment
|
||||||
|
CreateMap<EmailAttachmentDto, EmailAttachment>()
|
||||||
|
.ForMember(dest => dest.Id, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.EmailId, opt => opt.Ignore())
|
||||||
|
.ForMember(dest => dest.Email, opt => opt.Ignore());
|
||||||
|
CreateMap<EmailAttachment, EmailAttachmentDto>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
#if NET
|
#if NET
|
||||||
|
using AutoMapper;
|
||||||
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;
|
||||||
@@ -14,7 +15,7 @@ namespace DigitalData.MessagingService.Application.EmailReceiving.Queries;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Query to fetch emails from an IMAP mailbox.
|
/// Query to fetch emails from an IMAP mailbox.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
public record ReadEmailQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Identifies the email account to use.
|
/// Identifies the email account to use.
|
||||||
@@ -27,26 +28,24 @@ public record FetchEmailsQuery : IRequest<IEnumerable<ReceivedEmailDto>>
|
|||||||
public MailSearchFilter Mail { get; init; } = new();
|
public MailSearchFilter Mail { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public class FetchEmailsQueryHandler(IImapEmailService ImapService, ILogger<FetchEmailsQueryHandler> Logger, IRepository<EmailAccount> Repo) : IRequestHandler<FetchEmailsQuery, IEnumerable<ReceivedEmailDto>>
|
public class ReadEmailQueryHandler(IMapper Mapper, ILogger<ReadEmailQueryHandler> Logger, IRepository<EmailAccount> EmailAccountRepo, IReceivedEmailRepository MailRepo) : IRequestHandler<ReadEmailQuery, IEnumerable<ReceivedEmailDto>>
|
||||||
{
|
{
|
||||||
public async Task<IEnumerable<ReceivedEmailDto>> Handle(FetchEmailsQuery request, CancellationToken cancellationToken)
|
public async Task<IEnumerable<ReceivedEmailDto>> Handle(ReadEmailQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var accounts = await Repo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
var accounts = await EmailAccountRepo.FindAsync(request.Account.Id is int id ? x => x.Id == id : x => x.Username == request.Account.Username, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
if (accounts.Count() > 1)
|
if (accounts.Count() > 1)
|
||||||
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
Logger.LogWarning("Multiple email accounts found for the given criteria ({Criteria}). Returning the first one.", request.Account.Id is not null ? $"Id: {request.Account.Id}" : $"Username: {request.Account.Username}");
|
||||||
|
|
||||||
EmailAccount account = accounts.FirstOrDefault()
|
var account = accounts.FirstOrDefault()
|
||||||
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
?? throw new NotFoundException($"No email account found for the given criteria (Id: {request.Account.Id}, Username: {request.Account.Username}).");
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
if (string.IsNullOrWhiteSpace(account.ImapServer))
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
$"IMAP is not configured for account '{account.Username}' (Id: {account.Id}). Set ImapServer in EmailAccounts configuration.");
|
||||||
|
|
||||||
return await ImapService.FetchEmailsAsync(
|
var mails = await MailRepo.FindAsync(request.Mail, account, cancellationToken);
|
||||||
account,
|
return Mapper.Map<IEnumerable<ReceivedEmailDto>>(mails);
|
||||||
request.Mail,
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
#endif
|
#endif
|
||||||
@@ -5,9 +5,9 @@ using FluentValidation;
|
|||||||
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
namespace DigitalData.MessagingService.Application.EmailReceiving.Validators;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Validates a <see cref="FetchEmailsQuery"/> before it is handled by <see cref="FetchEmailsQueryHandler"/>.
|
/// Validates a <see cref="ReadEmailQuery"/> before it is handled by <see cref="ReadEmailQueryHandler"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class FetchEmailsQueryValidator : AbstractValidator<FetchEmailsQuery>
|
public class FetchEmailsQueryValidator : AbstractValidator<ReadEmailQuery>
|
||||||
{
|
{
|
||||||
public FetchEmailsQueryValidator()
|
public FetchEmailsQueryValidator()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -45,9 +45,9 @@ public sealed record ReceivedEmail
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Column("TO", TypeName = "nvarchar(max)")]
|
[Column("TO", TypeName = "nvarchar(max)")]
|
||||||
#if NET
|
#if NET
|
||||||
public IEnumerable<string> To { get; init; } = [];
|
public List<string> To { get; init; } = [];
|
||||||
#else
|
#else
|
||||||
public IEnumerable<string> To { get; set; } = [];
|
public List<string> To { get; set; } = [];
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -55,9 +55,9 @@ public sealed record ReceivedEmail
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
[Column("CC", TypeName = "nvarchar(max)")]
|
[Column("CC", TypeName = "nvarchar(max)")]
|
||||||
#if NET
|
#if NET
|
||||||
public IEnumerable<string> Cc { get; init; } = [];
|
public List<string> Cc { get; init; } = [];
|
||||||
#else
|
#else
|
||||||
public IEnumerable<string> Cc { get; set; } = [];
|
public List<string> Cc { get; set; } = [];
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -133,4 +133,10 @@ public sealed record ReceivedEmail
|
|||||||
#else
|
#else
|
||||||
public EmailAccount? Account { get; set; }
|
public EmailAccount? Account { get; set; }
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
|
#if NET
|
||||||
|
public required string Folder { get; init; }
|
||||||
|
#else
|
||||||
|
public string Folder { get; set; } = null!;
|
||||||
|
#endif
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
// Email Service - IMAP inbound (Limilabs Mail.dll)
|
||||||
// Fresh connection per call — stateless and thread-safe.
|
// Fresh connection per call — stateless and thread-safe.
|
||||||
services.AddSingleton<IImapEmailService, LimilabsImapEmailService>();
|
services.AddScoped<IImapEmailService, LimilabsImapEmailService>();
|
||||||
|
|
||||||
// PDF Processing Service (using DevExpress.Pdf)
|
// PDF Processing Service (using DevExpress.Pdf)
|
||||||
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
services.AddScoped<IPdfProcessingService, DevExpressPdfProcessingService>();
|
||||||
@@ -66,6 +66,8 @@ public static class DependencyInjection
|
|||||||
|
|
||||||
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
services.AddScoped(typeof(IRepository<>), typeof(Repository<>));
|
||||||
|
|
||||||
|
services.AddScoped<IReceivedEmailRepository, ReceivedEmailRepository>();
|
||||||
|
|
||||||
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
// AutoMapper - Register entity self-mappings (T -> T) for generic repository
|
||||||
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
services.AddAutoMapper(config => config.AddMaps(typeof(EntitySelfMappingProfile).Assembly));
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
using AutoMapper;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Dto.MailSearch;
|
||||||
|
using DigitalData.MessagingService.Application.Common.Interfaces.Repositories;
|
||||||
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
|
using DigitalData.MessagingService.Infrastructure.Persistence;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
||||||
|
|
||||||
|
public class ReceivedEmailRepository(MessagingServiceDbContext Context, IMapper Mapper) : Repository<ReceivedEmail>(Context, Mapper), IReceivedEmailRepository
|
||||||
|
{
|
||||||
|
public async Task<IEnumerable<ReceivedEmail>> FindAsync(MailSearchFilter mailSearchFilter, EmailAccount? accountQuery = null, CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
var query = DbSet.AsNoTracking();
|
||||||
|
|
||||||
|
// ── Account filter ─────────────────────────────────────────────────────
|
||||||
|
if (accountQuery is not null)
|
||||||
|
query = query.Where(x => x.AccountId == accountQuery.Id);
|
||||||
|
|
||||||
|
// ── Flag filters ───────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.UnseenOnly)
|
||||||
|
query = query.Where(x => !x.IsSeen);
|
||||||
|
|
||||||
|
// ── Text filters ───────────────────────────────────────────────────────
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SubjectContains))
|
||||||
|
query = query.Where(x => x.Subject.Contains(mailSearchFilter.SubjectContains));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.SenderContains))
|
||||||
|
query = query.Where(x => x.From.Contains(mailSearchFilter.SenderContains));
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.BodyContains))
|
||||||
|
query = query.Where(x => x.TextBody.Contains(mailSearchFilter.BodyContains)
|
||||||
|
|| x.HtmlBody.Contains(mailSearchFilter.BodyContains));
|
||||||
|
|
||||||
|
// ── UID filter ─────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.Uid is { } uid)
|
||||||
|
{
|
||||||
|
if (uid.Absolute.HasValue)
|
||||||
|
query = query.Where(x => x.Uid == uid.Absolute.Value);
|
||||||
|
else
|
||||||
|
{
|
||||||
|
if (uid.Min.HasValue)
|
||||||
|
query = query.Where(x => x.Uid >= uid.Min.Value);
|
||||||
|
if (uid.Max.HasValue)
|
||||||
|
query = query.Where(x => x.Uid <= uid.Max.Value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Date filter ────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.Date is { } date)
|
||||||
|
{
|
||||||
|
if (date.After.HasValue)
|
||||||
|
query = query.Where(x => x.Date >= date.After.Value);
|
||||||
|
if (date.Before.HasValue)
|
||||||
|
query = query.Where(x => x.Date <= date.Before.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Attachments ────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.WithAttachments)
|
||||||
|
query = query.Include(x => x.Attachments);
|
||||||
|
|
||||||
|
// ── Sort ───────────────────────────────────────────────────────────────
|
||||||
|
query = mailSearchFilter.SortOrder == MailSortOrder.OldestFirst
|
||||||
|
? query.OrderBy(x => x.Date)
|
||||||
|
: query.OrderByDescending(x => x.Date);
|
||||||
|
|
||||||
|
// ── Limit ──────────────────────────────────────────────────────────────
|
||||||
|
if (mailSearchFilter.MaxCount.HasValue)
|
||||||
|
query = query.Take(mailSearchFilter.MaxCount.Value);
|
||||||
|
|
||||||
|
// ── RecipientContains: To/Cc are IEnumerable<string> (nvarchar(max)) ──
|
||||||
|
// EF Core cannot translate collection predicates on these columns to SQL.
|
||||||
|
// Materialization is deferred until after other DB-side filters narrow the set.
|
||||||
|
var results = await query.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(mailSearchFilter.RecipientContains))
|
||||||
|
results = [.. results
|
||||||
|
.Where(x => x.To.Any(t => t.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase))
|
||||||
|
|| x.Cc.Any(c => c.Contains(mailSearchFilter.RecipientContains, StringComparison.OrdinalIgnoreCase)))];
|
||||||
|
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,22 +13,22 @@ namespace DigitalData.MessagingService.Infrastructure.Repositories;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapper) : IRepository<TEntity> where TEntity : class
|
||||||
{
|
{
|
||||||
private readonly DbSet<TEntity> _dbSet = Context.Set<TEntity>();
|
protected readonly DbSet<TEntity> DbSet = Context.Set<TEntity>();
|
||||||
|
|
||||||
// --- CREATE ---
|
// --- CREATE ---
|
||||||
|
|
||||||
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
public async Task<TEntity> CreateAsync<TDto>(TDto dto, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = Mapper.Map<TEntity>(dto);
|
var entity = Mapper.Map<TEntity>(dto);
|
||||||
await _dbSet.AddAsync(entity, cancellationToken);
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entity;
|
return entity;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> CreateAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<TEntity>> CreateRangeAsync<TDto>(IEnumerable<TDto> dtos, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
var entities = Mapper.Map<IEnumerable<TEntity>>(dtos);
|
||||||
await _dbSet.AddRangeAsync(entities, cancellationToken);
|
await DbSet.AddRangeAsync(entities, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities;
|
return entities;
|
||||||
}
|
}
|
||||||
@@ -37,12 +37,12 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
|
|
||||||
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
public async Task<TEntity?> GetByIdAsync(int id, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FindAsync([id], cancellationToken);
|
return await DbSet.FindAsync([id], cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
public async Task<IEnumerable<TEntity>> GetAllAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.ToListAsync(cancellationToken);
|
return await DbSet.ToListAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<IEnumerable<TEntity>> FindAsync(
|
public async Task<IEnumerable<TEntity>> FindAsync(
|
||||||
@@ -51,7 +51,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
int? take = null,
|
int? take = null,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var query = _dbSet.Where(predicate);
|
var query = DbSet.Where(predicate);
|
||||||
|
|
||||||
if (skip.HasValue)
|
if (skip.HasValue)
|
||||||
query = query.Skip(skip.Value);
|
query = query.Skip(skip.Value);
|
||||||
@@ -66,14 +66,14 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<TEntity?> FindSingleAsync(
|
public async Task<TEntity?> FindSingleAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
return await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<int> CountAsync(
|
public async Task<int> CountAsync(
|
||||||
@@ -81,15 +81,15 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return predicate == null
|
return predicate == null
|
||||||
? await _dbSet.CountAsync(cancellationToken)
|
? await DbSet.CountAsync(cancellationToken)
|
||||||
: await _dbSet.CountAsync(predicate, cancellationToken);
|
: await DbSet.CountAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<bool> AnyAsync(
|
public async Task<bool> AnyAsync(
|
||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
return await _dbSet.AnyAsync(predicate, cancellationToken);
|
return await DbSet.AnyAsync(predicate, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- UPSERT ---
|
// --- UPSERT ---
|
||||||
@@ -105,12 +105,12 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
var entity = await DbSet.FirstOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
{
|
{
|
||||||
entity = Mapper.Map<TEntity>(dto);
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
await _dbSet.AddAsync(entity, cancellationToken);
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return (entity, true);
|
return (entity, true);
|
||||||
}
|
}
|
||||||
@@ -130,12 +130,12 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken);
|
||||||
|
|
||||||
if (entity is null)
|
if (entity is null)
|
||||||
{
|
{
|
||||||
entity = Mapper.Map<TEntity>(dto);
|
entity = Mapper.Map<TEntity>(dto);
|
||||||
await _dbSet.AddAsync(entity, cancellationToken);
|
await DbSet.AddAsync(entity, cancellationToken);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return (entity, true);
|
return (entity, true);
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
Mapper.Map(dto, entity);
|
Mapper.Map(dto, entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
@@ -173,7 +173,7 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
TDto dto,
|
TDto dto,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
entities.ForEach(entity => Mapper.Map(dto, entity));
|
entities.ForEach(entity => Mapper.Map(dto, entity));
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
@@ -190,9 +190,9 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entity = await _dbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
var entity = await DbSet.SingleOrDefaultAsync(predicate, cancellationToken)
|
||||||
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
?? throw new NotFoundException($"No {typeof(TEntity).Name} found matching the predicate.");
|
||||||
_dbSet.Remove(entity);
|
DbSet.Remove(entity);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,8 +205,8 @@ public class Repository<TEntity>(MessagingServiceDbContext Context, IMapper Mapp
|
|||||||
Expression<Func<TEntity, bool>> predicate,
|
Expression<Func<TEntity, bool>> predicate,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var entities = await _dbSet.Where(predicate).ToListAsync(cancellationToken);
|
var entities = await DbSet.Where(predicate).ToListAsync(cancellationToken);
|
||||||
_dbSet.RemoveRange(entities);
|
DbSet.RemoveRange(entities);
|
||||||
await Context.SaveChangesAsync(cancellationToken);
|
await Context.SaveChangesAsync(cancellationToken);
|
||||||
return entities.Count;
|
return entities.Count;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,28 +1,22 @@
|
|||||||
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.Application.Common.Interfaces.Repositories;
|
||||||
using DigitalData.MessagingService.Application.Common.Options;
|
using DigitalData.MessagingService.Application.Common.Options;
|
||||||
using DigitalData.MessagingService.Domain.Entities;
|
using DigitalData.MessagingService.Domain.Entities;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
namespace DigitalData.MessagingService.Infrastructure.Services.Background;
|
||||||
|
|
||||||
public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccountsOptions> Options, IServiceProvider Provider) : BackgroundService
|
public class EmailSyncWorker(IOptions<EmailAccountsOptions> Options, IServiceProvider Provider, ILogger<EmailSyncWorker> Logger) : BackgroundService
|
||||||
{
|
{
|
||||||
private DateFilter? _dateFilter = null;
|
private readonly string DefaultFolder = "INBOX";
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
await UpsertSeedEmailAccount(stoppingToken);
|
await UpsertSeedEmailAccount(stoppingToken);
|
||||||
|
|
||||||
if (imapService is not LimilabsImapEmailService limapService)
|
|
||||||
{
|
|
||||||
await Task.Delay(Timeout.Infinite, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
var interval = TimeSpan.FromSeconds(Options.Value.SyncIntervalSeconds);
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
@@ -31,23 +25,23 @@ public class EmailSyncWorker(IImapEmailService imapService, IOptions<EmailAccoun
|
|||||||
|
|
||||||
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
var emailAccountRepo = scope.ServiceProvider.GetRequiredService<IRepository<EmailAccount>>();
|
||||||
|
|
||||||
|
var imapService = scope.ServiceProvider.GetRequiredService<IImapEmailService>();
|
||||||
|
|
||||||
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
foreach (var account in await emailAccountRepo.GetAllAsync(stoppingToken))
|
||||||
if (account.ImapServer is not null)
|
if (account.ImapServer is not null)
|
||||||
{
|
{
|
||||||
// init or update last date filter
|
Logger.LogDebug("Email synchronization has started for account {username} in folder {folder}.", account.Username, DefaultFolder);
|
||||||
_dateFilter = _dateFilter is null
|
|
||||||
? new DateFilter
|
try
|
||||||
{
|
{
|
||||||
After = null,
|
var res = await imapService.SyncEmailsAsync(account, DefaultFolder, stoppingToken);
|
||||||
Before = DateTime.UtcNow
|
Logger.LogDebug("Email synchronization has completed for account {username} in folder {folder}. Processed: {processedCount}, Failed: {failedCount}", account.Username, DefaultFolder, res.ProcessedCount, res.FailedCount);
|
||||||
}
|
}
|
||||||
: new DateFilter
|
catch(Exception ex)
|
||||||
{
|
{
|
||||||
After = _dateFilter.Before,
|
// Log the exception or handle it as needed
|
||||||
Before = DateTime.UtcNow
|
Logger.LogError(ex, "Error syncing emails for account {username}", account.Username);
|
||||||
};
|
}
|
||||||
|
|
||||||
await limapService.FetchEmailsAsync(account, new MailSearchFilter { Date = _dateFilter }, stoppingToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
await Task.Delay(interval, stoppingToken).ConfigureAwait(ConfigureAwaitOptions.SuppressThrowing);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
|
|
||||||
|
namespace DigitalData.MessagingService.Infrastructure.Services.Extensions;
|
||||||
|
|
||||||
|
public static class CacheExtensions
|
||||||
|
{
|
||||||
|
private readonly static string ImapCacheKeyPrefix = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
|
private static string CreateImapLastSyncDateCacheKey(int accountId, string folder)
|
||||||
|
{
|
||||||
|
return $"{ImapCacheKeyPrefix}_{accountId}_{folder}_LastImapSyncDate";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static DateTime? GetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder)
|
||||||
|
{
|
||||||
|
return cache.Get<DateTime?>(CreateImapLastSyncDateCacheKey(accountId, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SetLastImapSyncDate(this IMemoryCache cache, int accountId, string folder, DateTime date)
|
||||||
|
{
|
||||||
|
var key = CreateImapLastSyncDateCacheKey(accountId, folder);
|
||||||
|
cache.Set(key, date);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,12 @@
|
|||||||
using DigitalData.MessagingService.Application.Common.Dto;
|
using DigitalData.MessagingService.Application.Common.Dto;
|
||||||
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.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.Collections.Concurrent;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
|
||||||
namespace DigitalData.MessagingService.Infrastructure.Services;
|
namespace DigitalData.MessagingService.Infrastructure.Services;
|
||||||
@@ -16,7 +15,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, IMemoryCache Cache) : IImapEmailService
|
public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger, IRepository<ReceivedEmail> Repository) : IImapEmailService
|
||||||
{
|
{
|
||||||
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
private static readonly string CacheKeyPrefix = Guid.NewGuid().ToString();
|
||||||
|
|
||||||
@@ -26,173 +25,118 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
public async Task<IEnumerable<ReceivedEmailDto>> FetchEmailsAsync(
|
public async Task<EmailSyncResult> SyncEmailsAsync(EmailAccount account, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
EmailAccount account,
|
|
||||||
MailSearchFilter filter,
|
|
||||||
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)
|
var since = GetLastImapSyncDate(account.Id, folder);
|
||||||
{
|
|
||||||
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
|
if (since is not null && since != default)
|
||||||
if (dateF.Before is DateTime before)
|
criterions.Add(Expression.SentSince(since.Value));
|
||||||
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();
|
||||||
|
|
||||||
|
var operationStartTime = DateTime.UtcNow;
|
||||||
|
|
||||||
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)
|
|
||||||
return [];
|
|
||||||
|
|
||||||
var results = new List<ReceivedEmailDto>(uids.Count);
|
if (uids.Count == 0)
|
||||||
|
return new EmailSyncResult();
|
||||||
|
|
||||||
|
var emails = new List<ReceivedEmailDto>(uids.Count);
|
||||||
|
|
||||||
|
var failedCount = 0;
|
||||||
|
|
||||||
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
|
||||||
{
|
{
|
||||||
#region Read email
|
#region Read email
|
||||||
var email = await Cache.GetOrCreateAsync(
|
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
||||||
CacheKeyPrefix + uid,
|
var mail = new MailBuilder().CreateFromEml(eml);
|
||||||
async entry =>
|
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
||||||
{
|
|
||||||
var eml = await imap.GetMessageByUIDAsync(uid, cancel);
|
|
||||||
var mail = new MailBuilder().CreateFromEml(eml);
|
|
||||||
var flags = await imap.GetFlagsByUIDAsync(uid, cancel);
|
|
||||||
|
|
||||||
var attachments = new List<EmailAttachmentDto>();
|
var attachments = new List<EmailAttachmentDto>();
|
||||||
|
|
||||||
foreach (var att in mail.Attachments)
|
foreach (var att in mail.Attachments)
|
||||||
{
|
|
||||||
attachments.Add(new EmailAttachmentDto
|
|
||||||
{
|
|
||||||
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 EmailAttachmentDto
|
|
||||||
{
|
|
||||||
FileName = vis.FileName ?? "inline",
|
|
||||||
Content = vis.Data,
|
|
||||||
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
|
||||||
IsInline = true,
|
|
||||||
ContentId = vis.ContentId
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return new ReceivedEmailDto
|
|
||||||
{
|
|
||||||
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,
|
|
||||||
IsSeen = flags.Contains(Flag.Seen),
|
|
||||||
Attachments = attachments,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
#endregion Read email
|
|
||||||
|
|
||||||
if (email is null)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
if (filter.UnseenOnly && email.IsSeen)
|
|
||||||
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)
|
attachments.Add(new EmailAttachmentDto
|
||||||
continue;
|
{
|
||||||
|
FileName = att.FileName ?? "attachment",
|
||||||
if (uidF.Min is long min && email.Uid < min)
|
Content = att.Data,
|
||||||
continue;
|
ContentType = att.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = false,
|
||||||
if (uidF.Max is long max && email.Uid > max)
|
ContentId = att.ContentId
|
||||||
continue;
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (filter.WithAttachments)
|
foreach (var vis in mail.Visuals)
|
||||||
results.Add(email);
|
{
|
||||||
else
|
attachments.Add(new EmailAttachmentDto
|
||||||
results.Add(email with { Attachments = [] });
|
{
|
||||||
|
FileName = vis.FileName ?? "inline",
|
||||||
|
Content = vis.Data,
|
||||||
|
ContentType = vis.ContentType?.ToString() ?? "application/octet-stream",
|
||||||
|
IsInline = true,
|
||||||
|
ContentId = vis.ContentId
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var email = new ReceivedEmailDto
|
||||||
|
{
|
||||||
|
Uid = uid,
|
||||||
|
AccountId = account.Id,
|
||||||
|
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,
|
||||||
|
IsSeen = flags.Contains(Flag.Seen),
|
||||||
|
Attachments = attachments,
|
||||||
|
Folder = folder
|
||||||
|
};
|
||||||
|
#endregion Read email
|
||||||
|
|
||||||
|
emails.Add(email);
|
||||||
|
|
||||||
|
SetLastImapSyncDate(account.Id, folder, operationStartTime);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
failedCount += 1;
|
||||||
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.CreateRangeAsync(emails, cancel);
|
||||||
return results.Take(maxCount);
|
|
||||||
|
|
||||||
return results;
|
return new EmailSyncResult(ProcessedCount: emails.Count, FailedCount: failedCount);
|
||||||
}
|
}
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task MarkAsSeenAsync(
|
public async Task MarkAsSeenAsync(EmailAccount account, long uid, string folder = "INBOX", CancellationToken cancel = default)
|
||||||
EmailAccount account,
|
|
||||||
long uid,
|
|
||||||
string folder = "INBOX",
|
|
||||||
CancellationToken cancel = default)
|
|
||||||
{
|
{
|
||||||
using var imap = await OpenAsync(account, folder, cancel);
|
using var imap = await OpenAsync(account, folder, cancel);
|
||||||
try
|
try
|
||||||
@@ -200,21 +144,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();
|
||||||
|
|
||||||
@@ -232,4 +169,21 @@ public class LimilabsImapEmailService(ILogger<LimilabsImapEmailService> Logger,
|
|||||||
|
|
||||||
return imap;
|
return imap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#region IMAP Last Sync Date Cache
|
||||||
|
private readonly ConcurrentDictionary<ImapCacheKey, DateTime> _cache = new();
|
||||||
|
|
||||||
|
private record ImapCacheKey(int AccountId, string Folder);
|
||||||
|
|
||||||
|
public DateTime? GetLastImapSyncDate(int accountId, string folder = "INBOX")
|
||||||
|
{
|
||||||
|
return _cache.GetValueOrDefault(new ImapCacheKey(accountId, folder));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetLastImapSyncDate(int accountId, string folder, DateTime date)
|
||||||
|
{
|
||||||
|
var key = new ImapCacheKey(accountId, folder);
|
||||||
|
_cache[key] = date;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ public class EmailController(IMediator mediator) : ControllerBase
|
|||||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||||
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
[ProducesResponseType(StatusCodes.Status400BadRequest)]
|
||||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||||
public async Task<IActionResult> FetchEmails([FromQuery] FetchEmailsQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
public async Task<IActionResult> FetchEmails([FromQuery] ReadEmailQuery query, [FromQuery] OnlyFilter? only = null, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var emails = await mediator.Send(query, cancellationToken);
|
var emails = await mediator.Send(query, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user