< Summary

Information
Class: Services.EmailRoutingServices.ImapEmailInboxClient
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/ImapEmailInboxClient.cs
Line coverage
19%
Covered lines: 36
Uncovered lines: 147
Coverable lines: 183
Total lines: 368
Line coverage: 19.6%
Branch coverage
40%
Covered branches: 44
Total branches: 110
Branch coverage: 40%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.cctor()100%210%
.ctor(...)100%11100%
get_Settings()100%11100%
GetConnectionStatusAsync()100%11100%
IsTransientConnectionException(...)97.05%343494.73%
ConnectAndOpenInboxWithRetryAsync()0%4260%
FetchMessagesAsync()0%930300%
LoadRecentSummariesAsync()0%4260%
LoadUnprocessedSummariesAsync()0%2040%
ResolveMessageExternalId(...)100%22100%
GetExternalIdLookupKeys()75%121290%
MapMessage(...)0%110100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/ImapEmailInboxClient.cs

#LineLine coverage
 1using MailKit;
 2using MailKit.Net.Imap;
 3using MailKit.Search;
 4using Microsoft.Extensions.Logging;
 5using Microsoft.Extensions.Options;
 6using MimeKit;
 7
 8namespace Services.EmailRoutingServices;
 9
 10public class ImapEmailInboxClient : IEmailInboxClient
 11{
 12    private readonly IOptionsMonitor<ImapSettings> _settings;
 13    private readonly ILogger<ImapEmailInboxClient> _logger;
 14
 015    private static readonly MessageSummaryItems SummaryItems =
 016        MessageSummaryItems.UniqueId
 017        | MessageSummaryItems.Flags
 018        | MessageSummaryItems.Headers
 019        | MessageSummaryItems.InternalDate;
 20
 221    public ImapEmailInboxClient(IOptionsMonitor<ImapSettings> settings, ILogger<ImapEmailInboxClient> logger)
 22    {
 223        _settings = settings;
 224        _logger = logger;
 225    }
 26
 227    private ImapSettings Settings => _settings.CurrentValue;
 28
 29    public async Task<ImapConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default)
 230        => await ImapConnectionTester.CheckAsync(Settings, _logger, cancellationToken);
 31
 032    internal static readonly TimeSpan[] DefaultRetryDelays =
 033    [
 034        TimeSpan.FromSeconds(1),
 035        TimeSpan.FromSeconds(2),
 036        TimeSpan.FromSeconds(4)
 037    ];
 38
 39    /// <summary>
 40    /// Транзиентный сбой связи с IMAP-сервером (разрыв сокета, TLS, протокол, таймаут).
 41    /// Ошибки БД (<see cref="System.Data.Common.DbException"/>, <see cref="Microsoft.EntityFrameworkCore.DbUpdateExcept
 42    /// и файловой системы намеренно не считаются транзиентными, даже если внутри у них SocketException/IOException —
 43    /// иначе падение MariaDB в ProcessInboxAsync маскировалось бы под «временную недоступность почты».
 44    /// </summary>
 45    public static bool IsTransientConnectionException(Exception ex)
 46    {
 1647        if (ex is OperationCanceledException)
 48        {
 149            return false;
 50        }
 51
 1552        if (ex is MailKit.Security.AuthenticationException)
 53        {
 154            return false;
 55        }
 56
 1457        if (ex is System.Data.Common.DbException or Microsoft.EntityFrameworkCore.DbUpdateException)
 58        {
 259            return false;
 60        }
 61
 1262        if (ex is System.IO.FileNotFoundException or System.IO.DirectoryNotFoundException or System.IO.PathTooLongExcept
 63        {
 264            return false;
 65        }
 66
 1067        return ex switch
 1068        {
 169            MailKit.Net.Imap.ImapProtocolException => true,
 170            MailKit.Security.SslHandshakeException => true,
 171            MailKit.ServiceNotConnectedException => true,
 072            MailKit.ServiceNotAuthenticatedException => true,
 373            System.Net.Sockets.SocketException => true,
 174            System.IO.IOException => true,
 175            System.TimeoutException => true,
 276            _ => ex.InnerException != null && IsTransientConnectionException(ex.InnerException)
 1077        };
 78    }
 79
 80    private async Task<(ImapClient Client, IMailFolder Inbox)> ConnectAndOpenInboxWithRetryAsync(
 81        ImapSettings settings,
 82        FolderAccess access,
 83        CancellationToken cancellationToken)
 84    {
 85        const int maxAttempts = 3;
 086        var delays = DefaultRetryDelays;
 87
 088        for (var attempt = 1; attempt <= maxAttempts; attempt++)
 89        {
 090            var client = new ImapClient();
 91            try
 92            {
 093                await client.ConnectAsync(settings.Host, settings.Port, settings.UseSsl, cancellationToken);
 094                await client.AuthenticateAsync(settings.UserName, settings.Password, cancellationToken);
 95
 096                var inbox = client.GetFolder(settings.Folder);
 097                await inbox.OpenAsync(access, cancellationToken);
 98
 099                return (client, inbox);
 100            }
 0101            catch (Exception ex) when (attempt < maxAttempts && IsTransientConnectionException(ex) && !cancellationToken
 102            {
 0103                _logger.LogInformation(
 0104                    ex,
 0105                    "Временный сбой подключения к IMAP {Host}:{Port} (попытка {Attempt}/{MaxAttempts}): {Message}. Повто
 0106                    settings.Host,
 0107                    settings.Port,
 0108                    attempt,
 0109                    maxAttempts,
 0110                    ex.Message,
 0111                    delays[attempt - 1].TotalSeconds);
 112
 113                try
 114                {
 0115                    if (client.IsConnected)
 116                    {
 0117                        await client.DisconnectAsync(true, cancellationToken);
 118                    }
 0119                }
 0120                catch
 121                {
 122                    // Игнорируем вторичные ошибки разрыва при уже сбойном сокете
 0123                }
 124                finally
 125                {
 0126                    client.Dispose();
 127                }
 128
 0129                await Task.Delay(delays[attempt - 1], cancellationToken);
 0130            }
 0131            catch
 132            {
 133                try
 134                {
 0135                    if (client.IsConnected)
 136                    {
 0137                        await client.DisconnectAsync(true, cancellationToken);
 138                    }
 0139                }
 0140                catch
 141                {
 142                    // Игнорируем вторичные ошибки разрыва
 0143                }
 144                finally
 145                {
 0146                    client.Dispose();
 147                }
 148
 0149                throw;
 150            }
 0151        }
 152
 0153        throw new InvalidOperationException("Unreachable");
 0154    }
 155
 156    public async Task<List<InboxMessageDto>> FetchMessagesAsync(
 157        InboxKnownMessages? knownMessages = null,
 158        CancellationToken cancellationToken = default)
 159    {
 0160        var results = new List<InboxMessageDto>();
 0161        var settings = Settings;
 0162        if (string.IsNullOrWhiteSpace(settings.Host) || string.IsNullOrWhiteSpace(settings.UserName))
 163        {
 0164            _logger.LogWarning("IMAP settings are not configured. Skipping inbox polling.");
 0165            return results;
 166        }
 167
 0168        var (client, inbox) = await ConnectAndOpenInboxWithRetryAsync(settings, FolderAccess.ReadWrite, cancellationToke
 0169        using (client)
 170        {
 0171            if (inbox.Count == 0)
 172            {
 0173                await client.DisconnectAsync(true, cancellationToken);
 0174                return results;
 175            }
 176
 0177            var headerFetchCap = ImapFetchWindow.ResolveScanLimit(settings);
 0178            var summaries = await LoadUnprocessedSummariesAsync(inbox, knownMessages, headerFetchCap, cancellationToken)
 179
 180        // Окно по дате (по умолчанию последние 3 суток + сегодня, см. ImapFetchWindow.ResolveSinceUtc) — теперь
 181        // применяется всегда, а не только при явном FetchAfterUtc. Раньше без даты-порога свежие письма могли
 182        // застрять позади огромного FIFO-беклога недообработанных старых писем (headerFetchCap ограничен) —
 183        // актуальная почта не читалась, пока не дренируется весь беклог. Старую почту вне окна auto-polling
 184        // больше не трогает вовсе — она остаётся для ручного Reprocess/.cursor/skills/load-exist-orders/SKILL.md.
 0185        var sinceUtc = ImapFetchWindow.ResolveSinceUtc(settings);
 0186        var filtered = summaries.Where(s => (s.InternalDate?.UtcDateTime ?? DateTime.MinValue) >= sinceUtc);
 187
 0188        var candidates = filtered
 0189            .OrderByDescending(s => s.InternalDate ?? DateTimeOffset.MinValue) // новые (актуальные) — в приоритете
 0190            .ThenByDescending(s => s.UniqueId.Id)
 0191            .ToList();
 192
 0193        _logger.LogInformation(
 0194            "IMAP fetch: headerFetchCap {HeaderFetchCap}, fetched summaries {Scanned}, candidates {Candidates}, known ex
 0195            headerFetchCap,
 0196            summaries.Count,
 0197            candidates.Count,
 0198            knownMessages?.ExternalIds.Count ?? 0,
 0199            knownMessages?.Uids.Count ?? 0);
 200
 0201        foreach (var summary in candidates)
 202        {
 0203            if (results.Count >= settings.FetchLimit)
 204            {
 205                break;
 206            }
 207
 0208            var headerMessageId = summary.Headers?[HeaderId.MessageId];
 0209            if (knownMessages?.IsKnown(summary.UniqueId, headerMessageId) == true)
 210            {
 211                continue;
 212            }
 213
 0214            var message = await inbox.GetMessageAsync(summary.UniqueId, cancellationToken);
 0215            if (knownMessages?.IsKnown(summary.UniqueId, message.MessageId) == true)
 216            {
 217                continue;
 218            }
 219
 0220            var receivedAtUtc = summary.InternalDate?.UtcDateTime ?? message.Date.UtcDateTime;
 0221            results.Add(MapMessage(summary.UniqueId, message, receivedAtUtc));
 222
 0223            var flags = summary.Flags.GetValueOrDefault(MessageFlags.None);
 0224            if (!flags.HasFlag(MessageFlags.Seen))
 225            {
 0226                await inbox.AddFlagsAsync(summary.UniqueId, MessageFlags.Seen, true, cancellationToken);
 227            }
 0228        }
 229
 0230        await client.DisconnectAsync(true, cancellationToken);
 0231            return results;
 232        }
 0233    }
 234
 235    /// <summary>
 236    /// Используется только диагностикой (<see cref="ImapConnectionTester"/>) для UI-статуса
 237    /// "сколько писем готово" — старое поведение (топ-N свежих UID + непрочитанные), не влияет на pipeline.
 238    /// </summary>
 239    internal static async Task<IList<IMessageSummary>> LoadRecentSummariesAsync(
 240        IMailFolder inbox,
 241        int scanLimit,
 242        CancellationToken cancellationToken)
 243    {
 0244        var summaries = new List<IMessageSummary>();
 245
 0246        var allUids = await inbox.SearchAsync(SearchQuery.All, cancellationToken);
 0247        if (allUids.Count > 0)
 248        {
 0249            var recentUids = allUids
 0250                .OrderByDescending(x => x.Id)
 0251                .Take(scanLimit)
 0252                .ToList();
 0253            var recentSummaries = await inbox.FetchAsync(recentUids, SummaryItems, cancellationToken);
 0254            summaries.AddRange(recentSummaries);
 255        }
 256
 0257        var unseenUids = await inbox.SearchAsync(SearchQuery.NotSeen, cancellationToken);
 0258        if (unseenUids.Count > 0)
 259        {
 0260            var knownUids = summaries.Select(x => x.UniqueId).ToHashSet();
 0261            var extraUnseen = unseenUids.Where(uid => !knownUids.Contains(uid)).ToList();
 0262            if (extraUnseen.Count > 0)
 263            {
 0264                var extraSummaries = await inbox.FetchAsync(extraUnseen, SummaryItems, cancellationToken);
 0265                summaries.AddRange(extraSummaries);
 266            }
 267        }
 268
 0269        return summaries
 0270            .GroupBy(x => x.UniqueId)
 0271            .Select(g => g.First())
 0272            .ToList();
 0273    }
 274
 275    /// <summary>
 276    /// Используется реальным pipeline обработки почты (<see cref="FetchMessagesAsync"/>).
 277    /// В отличие от <see cref="LoadRecentSummariesAsync"/>, источник истины "обработано/нет" —
 278    /// не "последние N по дате/UID", а фактический набор уже известных писем в БД (<paramref name="knownMessages"/>):
 279    /// сначала дёшево получаем полный список UID в папке (без заголовков), локально отсеиваем уже известные,
 280    /// и только для действительно новых/необработанных запрашиваем заголовки (ограничено headerFetchCap).
 281    /// </summary>
 282    internal static async Task<IList<IMessageSummary>> LoadUnprocessedSummariesAsync(
 283        IMailFolder inbox,
 284        InboxKnownMessages? knownMessages,
 285        int headerFetchCap,
 286        CancellationToken cancellationToken)
 287    {
 0288        var allUids = await inbox.SearchAsync(SearchQuery.All, cancellationToken);
 0289        if (allUids.Count == 0)
 0290            return new List<IMessageSummary>();
 291
 0292        var unknownUids = allUids
 0293            .Where(uid => knownMessages == null || !knownMessages.Uids.Contains(uid.Id.ToString()))
 0294            .OrderByDescending(uid => uid.Id) // новые в приоритете — не дать старому беклогу съесть headerFetchCap
 0295            .Take(headerFetchCap)              // защитный cap, чтобы не тянуть заголовки огромного архива разом
 0296            .ToList();
 297
 0298        if (unknownUids.Count == 0)
 0299            return new List<IMessageSummary>();
 300
 0301        return await inbox.FetchAsync(unknownUids, SummaryItems, cancellationToken);
 0302    }
 303
 304    public static string ResolveMessageExternalId(UniqueId uid, string? headerMessageId) =>
 2305        string.IsNullOrWhiteSpace(headerMessageId)
 2306            ? $"uid-{uid.Id}"
 2307            : headerMessageId.Trim();
 308
 309    public static IEnumerable<string> GetExternalIdLookupKeys(UniqueId uid, string? messageId)
 310    {
 6311        var trimmed = string.IsNullOrWhiteSpace(messageId) ? null : messageId.Trim();
 6312        if (!string.IsNullOrEmpty(trimmed))
 313        {
 5314            yield return trimmed;
 4315            if (trimmed.StartsWith('<') && trimmed.EndsWith('>') && trimmed.Length > 2)
 316            {
 4317                var inner = trimmed[1..^1].Trim();
 4318                if (!string.IsNullOrEmpty(inner))
 319                {
 4320                    yield return inner;
 321                }
 322            }
 323            else
 324            {
 0325                yield return $"<{trimmed}>";
 326            }
 327        }
 328
 4329        yield return $"uid-{uid.Id}";
 4330    }
 331
 332    private static InboxMessageDto MapMessage(UniqueId uid, MimeMessage message, DateTime receivedAtUtc)
 333    {
 0334        var dto = new InboxMessageDto
 0335        {
 0336            MessageExternalId = ResolveMessageExternalId(uid, message.MessageId),
 0337            Uid = uid.Id.ToString(),
 0338            SenderEmail = message.From.Mailboxes.FirstOrDefault()?.Address,
 0339            Subject = message.Subject,
 0340            BodyText = message.TextBody ?? message.HtmlBody,
 0341            ReceivedAtUtc = receivedAtUtc,
 0342            Attachments = new List<InboxAttachmentDto>()
 0343        };
 344
 0345        foreach (var entity in message.Attachments)
 346        {
 0347            if (entity is not MimePart part)
 348            {
 349                continue;
 350            }
 351
 0352            using var stream = new MemoryStream();
 0353            part.Content.DecodeTo(stream);
 0354            var bytes = stream.ToArray();
 0355            var fileName = string.IsNullOrWhiteSpace(part.FileName) ? $"attachment-{Guid.NewGuid():N}" : part.FileName;
 356
 0357            dto.Attachments.Add(new InboxAttachmentDto
 0358            {
 0359                FileName = fileName,
 0360                Extension = Path.GetExtension(fileName).ToLowerInvariant(),
 0361                FileSizeBytes = bytes.LongLength,
 0362                FileBytes = bytes
 0363            });
 364        }
 365
 0366        return dto;
 367    }
 368}