< Summary

Information
Class: Services.EmailRoutingServices.ImapConnectionStatus
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/EmailRoutingContracts.cs
Line coverage
81%
Covered lines: 9
Uncovered lines: 2
Coverable lines: 11
Total lines: 197
Line coverage: 81.8%
Branch coverage
N/A
Covered branches: 0
Total branches: 0
Branch coverage: N/A
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
get_IsConfigured()100%11100%
get_IsConnected()100%11100%
get_UserName()100%11100%
get_Host()100%11100%
get_Port()100%11100%
get_Folder()100%11100%
get_FetchLimit()100%11100%
get_UnseenCount()100%210%
get_UnseenReadyCount()100%210%
get_ErrorMessage()100%11100%
get_CheckedAtUtc()100%11100%

File(s)

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

#LineLine coverage
 1using OzonDomains.Models;
 2
 3namespace Services.EmailRoutingServices;
 4
 5public class ImapSettings
 6{
 7    public string Host { get; set; } = string.Empty;
 8    public int Port { get; set; } = 993;
 9    public string UserName { get; set; } = string.Empty;
 10    public string Password { get; set; } = string.Empty;
 11    public bool UseSsl { get; set; } = true;
 12    public string Folder { get; set; } = "INBOX";
 13    public int FetchLimit { get; set; } = 20;
 14
 15    /// <summary>
 16    /// Сколько последних писем в папке (по UID) просматривать за один запуск.
 17    /// </summary>
 18    public int FetchScanLimit { get; set; } = ImapFetchWindow.DefaultFetchScanLimit;
 19
 20    /// <summary>
 21    /// Скользящее окно в сутках — всегда действует при автополлинге (ImapEmailInboxClient.FetchMessagesAsync),
 22    /// если не задан <see cref="FetchAfterUtc"/>. Фактическое окно — min(N суток назад, начало текущих суток MSK),
 23    /// то есть всегда покрывает минимум "сегодня". Письма старше окна auto-polling не обрабатывает вовсе —
 24    /// только вручную (Reprocess/.cursor/skills/load-exist-orders/SKILL.md).
 25    /// </summary>
 26    public int FetchWindowDays { get; set; } = ImapFetchWindow.DefaultFetchWindowDays;
 27
 28    // Если задано, ПОЛНОСТЬЮ заменяет скользящее окно FetchWindowDays: IMAP будет выбирать только письма,
 29    // полученные после этой фиксированной даты (UTC), пока её не изменят.
 30    public DateTime? FetchAfterUtc { get; set; }
 31}
 32
 33public class ImapConnectionStatus
 34{
 2635    public bool IsConfigured { get; set; }
 1636    public bool IsConnected { get; set; }
 1637    public string? UserName { get; set; }
 1538    public string? Host { get; set; }
 1539    public int Port { get; set; }
 1540    public string? Folder { get; set; }
 1541    public int FetchLimit { get; set; }
 042    public int? UnseenCount { get; set; }
 43
 44    /// <summary>
 45    /// Все письма в папке за окно FetchAfterUtc (обычно 24ч), включая прочитанные.
 46    /// </summary>
 047    public int? UnseenReadyCount { get; set; }
 2548    public string? ErrorMessage { get; set; }
 2949    public DateTime CheckedAtUtc { get; set; } = DateTime.UtcNow;
 50}
 51
 52public class EmailRoutingExecutionOptions
 53{
 54    public bool AllowMultiMatch { get; set; }
 55
 56    public bool RecurringJobEnabled { get; set; } = true;
 57
 58    public int IntervalMinutes { get; set; } = 5;
 59
 60    /// <summary>
 61    /// Сколько раз подряд вызывать IMAP-polling за один Hangfire-запуск (каждый раз до FetchLimit писем).
 62    /// </summary>
 63    public int MaxBatchesPerRun { get; set; } = 20;
 64
 65    /// <summary>
 66    /// Максимальный возраст письма (по ReceivedAtUtc) для прямого автоимпорта заказов Exist/Эмекс
 67    /// (OrderEmailImportService) без подтверждения человеком — применяется только к обычному IMAP polling.
 68    /// Письма старше — не импортируются автоматически (Status=Failed с понятной причиной), обрабатываются
 69    /// только вручную: либо через Reprocess одного письма из EmailRoutingResults (человек явно подтвердил),
 70    /// либо по .cursor/skills/load-exist-orders/SKILL.md. Защита от повторного автоматического импорта беклога
 71    /// при рассинхроне состояния "уже обработано" (например после восстановления БД из бэкапа) — см. инцидент
 72    /// 17.07.2026, когда 1205 старых заказов получили ProcessingDate=Now и часть данных перезаписалась чужим
 73    /// Key-совпадением. Ручной Reprocess этот cutoff обходит — см. IOrderEmailImportService.TryImportAsync.
 74    /// Должен быть с запасом больше окна IMAP-polling (<see cref="ImapSettings.FetchWindowDays"/>, по умолчанию
 75    /// 3 суток = 72ч) — иначе письмо, впервые попавшее в выборку на границе окна, тут же упирается в cutoff
 76    /// (см. инцидент 19.07.2026: письма 17.07 не проходили при cutoff=48ч). Значение переопределяется в
 77    /// appsettings.json (EmailRouting:OrderImportMaxBacklogHours) — сейчас 96ч.
 78    /// </summary>
 79    public double OrderImportMaxBacklogHours { get; set; } = 48;
 80}
 81
 82public class InboxMessageDto
 83{
 84    public string MessageExternalId { get; set; } = string.Empty;
 85    public string? Uid { get; set; }
 86    public string? SenderEmail { get; set; }
 87    public string? Subject { get; set; }
 88    public string? BodyText { get; set; }
 89    public DateTime? ReceivedAtUtc { get; set; }
 90    public List<InboxAttachmentDto> Attachments { get; set; } = new();
 91}
 92
 93public class InboxAttachmentDto
 94{
 95    public string FileName { get; set; } = string.Empty;
 96    public string? Extension { get; set; }
 97    public long FileSizeBytes { get; set; }
 98    public byte[] FileBytes { get; set; } = Array.Empty<byte>();
 99}
 100
 101public class RuleEvaluationResult
 102{
 103    public bool IsMatch { get; set; }
 104    public string? RejectReason { get; set; }
 105    public EmailRoutingRule? Rule { get; set; }
 106}
 107
 108public class DropboxRouteResult
 109{
 110    public bool IsSuccess { get; set; }
 111    public string? FolderPath { get; set; }
 112    public string? FileName { get; set; }
 113    public string? FilePath { get; set; }
 114    public string? DownloadLink { get; set; }
 115    public string? Error { get; set; }
 116}
 117
 118public class OrderImportResult
 119{
 120    /// <summary>
 121    /// true, если правило распознано как заказ-импортирующее (Exist/Эмекс) — Dropbox для него не используется,
 122    /// независимо от IsSuccess. false — правило не про импорт заказов, обработку нужно продолжить как обычно (Dropbox).
 123    /// </summary>
 124    public bool Handled { get; set; }
 125    public bool IsSuccess { get; set; }
 126    public string? MappingName { get; set; }
 127    public string? Delivery { get; set; }
 128    public int AddedCount { get; set; }
 129    public int UpdatedCount { get; set; }
 130
 131    /// <summary>
 132    /// Сколько строк файла пропущено, потому что заказ с таким (Article, ShipmentNumber) уже есть в базе —
 133    /// файл поставщика ничего обновить не может, такие строки не трогаем.
 134    /// </summary>
 135    public int SkippedExistingCount { get; set; }
 136
 137    /// <summary>
 138    /// Номера отгрузок (ShipmentNumber) реально добавленных заказов — для отображения в EmailRoutingResults.
 139    /// </summary>
 140    public List<string> AddedOrderNumbers { get; set; } = new();
 141
 142    public string? Error { get; set; }
 143}
 144
 145public interface IEmailInboxClient
 146{
 147    Task<List<InboxMessageDto>> FetchMessagesAsync(
 148        InboxKnownMessages? knownMessages = null,
 149        CancellationToken cancellationToken = default);
 150
 151    Task<ImapConnectionStatus> GetConnectionStatusAsync(CancellationToken cancellationToken = default);
 152}
 153
 154public interface IRoutingRuleEvaluator
 155{
 156    RuleEvaluationResult Evaluate(InboxMessageDto message, InboxAttachmentDto attachment, List<EmailRoutingRule> rules);
 157}
 158
 159public interface IFileDeduplicationService
 160{
 161    string ComputeSha256(byte[] bytes);
 162    Task<bool> IsDuplicateAsync(string messageExternalId, string hash, string fileName, long fileSizeBytes);
 163}
 164
 165public interface IDropboxFileRouter
 166{
 167    Task<DropboxRouteResult> RouteAsync(EmailRoutingRule rule, InboxMessageDto message, InboxAttachmentDto attachment, s
 168}
 169
 170/// <summary>
 171/// Прямой импорт заказов из вложения письма (Exist/Эмекс) в Orders, минуя Dropbox —
 172/// переиспользует штатный парсинг OrderCaster/ColumnMapping, которым раньше пользовался
 173/// отключённый Dropbox-джоб FileUpload (см. .cursor/skills/load-exist-orders/SKILL.md).
 174/// </summary>
 175public interface IOrderEmailImportService
 176{
 177    /// <summary>
 178    /// <paramref name="forceReprocess"/> = true (ручной Reprocess одного письма через UI) обходит только
 179    /// возрастной cutoff (<see cref="EmailRoutingExecutionOptions.OrderImportMaxBacklogHours"/>) — человек
 180    /// явно запросил переобработку этого письма. Защиту от порчи данных при этом даёт не cutoff, а insert-only
 181    /// вставка (<see cref="OrderImportResult.SkippedExistingCount"/>) — совпавшие по (Article, ShipmentNumber)
 182    /// строки по-прежнему никогда не апдейтятся. Письма без определённой даты (ReceivedAtUtc == null)
 183    /// cutoff не обходит — без даты не на что ссылаться при смещении даты отгрузки.
 184    /// </summary>
 185    Task<OrderImportResult> TryImportAsync(
 186        EmailRoutingRule rule,
 187        InboxMessageDto message,
 188        InboxAttachmentDto attachment,
 189        bool forceReprocess = false,
 190        CancellationToken cancellationToken = default);
 191}
 192
 193public interface IEmailAttachmentRoutingService
 194{
 195    Task<EmailInboxProcessResult> ProcessInboxAsync(bool dryRun = false, CancellationToken cancellationToken = default);
 196    Task<int> ReprocessMessageAsync(long messageId, CancellationToken cancellationToken = default);
 197}