< Summary

Information
Class: Services.EmailRoutingServices.EmailAttachmentRoutingService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/EmailRoutingServices/EmailAttachmentRoutingService.cs
Line coverage
52%
Covered lines: 120
Uncovered lines: 108
Coverable lines: 228
Total lines: 349
Line coverage: 52.6%
Branch coverage
54%
Covered branches: 37
Total branches: 68
Branch coverage: 54.4%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ProcessInboxAsync()50%111076.92%
ReprocessMessageAsync()0%620%
ProcessMessageInternal()62.5%2984852.28%
BuildOrdersImportedReason(...)0%2040%
IsAttachmentAlreadyStored(...)50%44100%

File(s)

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

#LineLine coverage
 1using Microsoft.EntityFrameworkCore;
 2using Microsoft.Extensions.Logging;
 3using Microsoft.Extensions.Options;
 4using OzonDomains.Models;
 5using OzonRepositories.Context;
 6using Services.DataServices;
 7using Services.SystemMonitoringServices;
 8
 9namespace Services.EmailRoutingServices;
 10
 11public class EmailAttachmentRoutingService : IEmailAttachmentRoutingService
 12{
 13    private readonly IEmailInboxClient _emailInboxClient;
 14    private readonly IRoutingRuleEvaluator _ruleEvaluator;
 15    private readonly IFileDeduplicationService _deduplicationService;
 16    private readonly IDropboxFileRouter _dropboxFileRouter;
 17    private readonly IOrderEmailImportService _orderEmailImportService;
 18    private readonly EmailRoutingRuleDataServices _rulesDataServices;
 19    private readonly EmailMessageInboxDataServices _messageDataServices;
 20    private readonly EmailAttachmentProcessingResultDataServices _resultDataServices;
 21    private readonly ApplicationDbContext _db;
 22    private readonly EmailRoutingExecutionOptions _executionOptions;
 23    private readonly ILogger<EmailAttachmentRoutingService> _logger;
 24    private readonly ISystemMonitoringSignalWriter? _signals;
 25
 526    public EmailAttachmentRoutingService(
 527        IEmailInboxClient emailInboxClient,
 528        IRoutingRuleEvaluator ruleEvaluator,
 529        IFileDeduplicationService deduplicationService,
 530        IDropboxFileRouter dropboxFileRouter,
 531        IOrderEmailImportService orderEmailImportService,
 532        EmailRoutingRuleDataServices rulesDataServices,
 533        EmailMessageInboxDataServices messageDataServices,
 534        EmailAttachmentProcessingResultDataServices resultDataServices,
 535        ApplicationDbContext db,
 536        IOptions<EmailRoutingExecutionOptions> executionOptions,
 537        ILogger<EmailAttachmentRoutingService> logger,
 538        ISystemMonitoringSignalWriter? signals = null)
 39    {
 540        _emailInboxClient = emailInboxClient;
 541        _ruleEvaluator = ruleEvaluator;
 542        _deduplicationService = deduplicationService;
 543        _dropboxFileRouter = dropboxFileRouter;
 544        _orderEmailImportService = orderEmailImportService;
 545        _rulesDataServices = rulesDataServices;
 546        _messageDataServices = messageDataServices;
 547        _resultDataServices = resultDataServices;
 548        _db = db;
 549        _executionOptions = executionOptions.Value;
 550        _logger = logger;
 551        _signals = signals;
 552    }
 53
 54    public async Task<EmailInboxProcessResult> ProcessInboxAsync(bool dryRun = false, CancellationToken cancellationToke
 55    {
 56        try
 57        {
 658            var rules = await _rulesDataServices.GetActiveRules();
 659            var knownMessages = await _messageDataServices.GetKnownInboxMessagesAsync();
 660            var messages = await _emailInboxClient.FetchMessagesAsync(knownMessages, cancellationToken);
 661            var batchResult = new EmailInboxProcessResult { BatchesProcessed = 1 };
 62
 2263            foreach (var message in messages)
 64            {
 65                // В обычном polling обрабатываем только новые письма.
 66                // Письма, которые уже есть в БД, не трогаем (переобработка — только через Reprocess).
 567                batchResult.Add(await ProcessMessageInternal(
 568                    message, rules, dryRun, cancellationToken, forceReprocess: false));
 69            }
 70
 671            await (_signals?.RecordAsync("email-routing", "success", cancellationToken: cancellationToken) ?? Task.Compl
 672            return batchResult;
 73        }
 074        catch (Exception) when (!cancellationToken.IsCancellationRequested)
 75        {
 076            await (_signals?.RecordAsync("email-routing", "failure", message: "Ошибка обработки входящей почты", cancell
 077            throw;
 78        }
 679    }
 80
 81    public async Task<int> ReprocessMessageAsync(long messageId, CancellationToken cancellationToken = default)
 82    {
 083        var message = await _db.EmailMessageInboxes
 084            .Include(x => x.Attachments)
 085            .FirstOrDefaultAsync(x => x.Id == messageId, cancellationToken);
 086        if (message == null)
 87        {
 088            return 0;
 89        }
 90
 091        var dto = new InboxMessageDto
 092        {
 093            MessageExternalId = message.MessageExternalId,
 094            Uid = message.Uid,
 095            SenderEmail = message.SenderEmail,
 096            Subject = message.Subject,
 097            BodyText = message.BodyText,
 098            ReceivedAtUtc = message.ReceivedAtUtc,
 099            Attachments = message.Attachments.Select(x => new InboxAttachmentDto
 0100            {
 0101                FileName = x.FileName,
 0102                Extension = x.Extension,
 0103                FileSizeBytes = x.FileSizeBytes,
 0104                FileBytes = x.FileBytes
 0105            }).ToList()
 0106        };
 107
 0108        var rules = await _rulesDataServices.GetActiveRules();
 109        // Вручную переобрабатываем письмо, даже если оно уже было обработано ранее.
 0110        var result = await ProcessMessageInternal(dto, rules, false, cancellationToken, forceReprocess: true);
 0111        return result.MessagesProcessed;
 0112    }
 113
 114    private async Task<EmailInboxProcessResult> ProcessMessageInternal(
 115        InboxMessageDto messageDto,
 116        List<EmailRoutingRule> rules,
 117        bool dryRun,
 118        CancellationToken cancellationToken,
 119        bool forceReprocess)
 120    {
 5121        var existing = await _messageDataServices.GetByExternalId(messageDto.MessageExternalId);
 5122        if (existing == null && !string.IsNullOrWhiteSpace(messageDto.Uid))
 123        {
 3124            existing = await _messageDataServices.GetByUid(messageDto.Uid);
 125        }
 126
 5127        if (existing != null && !forceReprocess)
 128        {
 3129            _logger.LogInformation(
 3130                "Skip already known message {MessageExternalId} (uid {Uid}, forceReprocess=false). Current status: {Stat
 3131                messageDto.MessageExternalId,
 3132                messageDto.Uid,
 3133                existing.ProcessingStatus);
 3134            return new EmailInboxProcessResult();
 135        }
 136
 2137        var result = new EmailInboxProcessResult();
 138
 2139        var messageEntity = existing ?? new EmailMessageInbox
 2140        {
 2141            MessageExternalId = messageDto.MessageExternalId,
 2142            Uid = messageDto.Uid,
 2143            SenderEmail = messageDto.SenderEmail,
 2144            Subject = messageDto.Subject,
 2145            BodyText = messageDto.BodyText,
 2146            ReceivedAtUtc = messageDto.ReceivedAtUtc,
 2147            CreatedAt = DateTime.UtcNow,
 2148            ProcessingStatus = EmailMessageProcessingStatus.New
 2149        };
 150
 2151        if (existing == null)
 152        {
 2153            await _messageDataServices.AddInboxMessage(messageEntity);
 154        }
 155
 2156        var hadErrors = false;
 2157        var hadWarnings = false;
 2158        var matchedCount = 0;
 159
 10160        foreach (var attachmentDto in messageDto.Attachments)
 161        {
 3162            var hash = _deduplicationService.ComputeSha256(attachmentDto.FileBytes);
 3163            var messageKey = existing?.MessageExternalId ?? messageDto.MessageExternalId;
 164
 3165            if (!forceReprocess && IsAttachmentAlreadyStored(messageEntity, hash, attachmentDto))
 166            {
 1167                _logger.LogInformation(
 1168                    "Skip duplicate attachment {FileName} for message {MessageExternalId}",
 1169                    attachmentDto.FileName,
 1170                    messageKey);
 1171                hadWarnings = true;
 1172                result.AttachmentsDuplicate++;
 1173                continue;
 174            }
 175
 2176            if (!forceReprocess && await _deduplicationService.IsDuplicateAsync(
 2177                    messageKey, hash, attachmentDto.FileName, attachmentDto.FileSizeBytes))
 178            {
 0179                _logger.LogInformation(
 0180                    "Skip duplicate attachment {FileName} already stored for message {MessageExternalId}",
 0181                    attachmentDto.FileName,
 0182                    messageKey);
 0183                hadWarnings = true;
 0184                result.AttachmentsDuplicate++;
 0185                continue;
 186            }
 187
 2188            var attachmentEntity = new EmailAttachmentInbox
 2189            {
 2190                EmailMessageInboxId = messageEntity.Id,
 2191                FileName = attachmentDto.FileName,
 2192                Extension = attachmentDto.Extension,
 2193                FileSizeBytes = attachmentDto.FileSizeBytes,
 2194                Sha256Hash = hash,
 2195                FileBytes = attachmentDto.FileBytes,
 2196                CreatedAt = DateTime.UtcNow
 2197            };
 2198            _db.EmailAttachmentInboxes.Add(attachmentEntity);
 2199            await _db.SaveChangesAsync(cancellationToken);
 200
 2201            var ruleResult = _ruleEvaluator.Evaluate(messageDto, attachmentDto, rules);
 2202            if (!ruleResult.IsMatch || ruleResult.Rule == null)
 203            {
 2204                hadWarnings = true;
 2205                result.AttachmentsRejected++;
 2206                await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 2207                {
 2208                    EmailMessageInboxId = messageEntity.Id,
 2209                    EmailAttachmentInboxId = attachmentEntity.Id,
 2210                    Status = EmailAttachmentProcessingStatus.RejectedByRule,
 2211                    Reason = ruleResult.RejectReason
 2212                });
 2213                continue;
 214            }
 215
 0216            matchedCount++;
 0217            if (dryRun)
 218            {
 0219                result.AttachmentsPending++;
 0220                await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 0221                {
 0222                    EmailMessageInboxId = messageEntity.Id,
 0223                    EmailAttachmentInboxId = attachmentEntity.Id,
 0224                    EmailRoutingRuleId = ruleResult.Rule.Id,
 0225                    Status = EmailAttachmentProcessingStatus.Pending,
 0226                    Reason = "Dry-run: загрузка не выполнялась."
 0227                });
 0228                continue;
 229            }
 230
 0231            var importResult = await _orderEmailImportService.TryImportAsync(
 0232                ruleResult.Rule, messageDto, attachmentDto, forceReprocess, cancellationToken);
 233
 0234            if (importResult.Handled)
 235            {
 0236                if (!importResult.IsSuccess)
 237                {
 0238                    hadErrors = true;
 0239                    result.AttachmentsFailed++;
 0240                    await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 0241                    {
 0242                        EmailMessageInboxId = messageEntity.Id,
 0243                        EmailAttachmentInboxId = attachmentEntity.Id,
 0244                        EmailRoutingRuleId = ruleResult.Rule.Id,
 0245                        Status = EmailAttachmentProcessingStatus.Failed,
 0246                        Reason = $"Ошибка импорта заказов ({importResult.MappingName}).",
 0247                        Error = importResult.Error
 0248                    });
 0249                    continue;
 250                }
 251
 0252                result.AttachmentsOrdersImported++;
 0253                await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 0254                {
 0255                    EmailMessageInboxId = messageEntity.Id,
 0256                    EmailAttachmentInboxId = attachmentEntity.Id,
 0257                    EmailRoutingRuleId = ruleResult.Rule.Id,
 0258                    Status = EmailAttachmentProcessingStatus.OrdersImported,
 0259                    Reason = BuildOrdersImportedReason(importResult)
 0260                });
 261            }
 262            else
 263            {
 0264                var route = await _dropboxFileRouter.RouteAsync(ruleResult.Rule, messageDto, attachmentDto, hash);
 0265                if (!route.IsSuccess)
 266                {
 0267                    hadErrors = true;
 0268                    result.AttachmentsFailed++;
 0269                    await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 0270                    {
 0271                        EmailMessageInboxId = messageEntity.Id,
 0272                        EmailAttachmentInboxId = attachmentEntity.Id,
 0273                        EmailRoutingRuleId = ruleResult.Rule.Id,
 0274                        Status = EmailAttachmentProcessingStatus.Failed,
 0275                        Reason = "Ошибка загрузки в Dropbox.",
 0276                        Error = route.Error
 0277                    });
 0278                    continue;
 279                }
 280
 0281                result.AttachmentsUploaded++;
 0282                await _resultDataServices.AddResult(new EmailAttachmentProcessingResult
 0283                {
 0284                    EmailMessageInboxId = messageEntity.Id,
 0285                    EmailAttachmentInboxId = attachmentEntity.Id,
 0286                    EmailRoutingRuleId = ruleResult.Rule.Id,
 0287                    Status = EmailAttachmentProcessingStatus.Uploaded,
 0288                    Reason = "Файл успешно загружен в Dropbox.",
 0289                    DropboxFolderPath = route.FolderPath,
 0290                    DropboxFileName = route.FileName,
 0291                    DropboxFilePath = route.FilePath,
 0292                    DropboxDownloadLink = route.DownloadLink
 0293                });
 294            }
 295
 0296            if (ruleResult.Rule.ProcessOnlyFirstMatchedAttachment && !_executionOptions.AllowMultiMatch)
 297            {
 298                break;
 299            }
 0300        }
 301
 2302        messageEntity.ProcessedAtUtc = DateTime.UtcNow;
 2303        messageEntity.ProcessingStatus = hadErrors
 2304            ? EmailMessageProcessingStatus.Failed
 2305            : hadWarnings
 2306                ? EmailMessageProcessingStatus.ProcessedWithWarnings
 2307                : EmailMessageProcessingStatus.ProcessedSuccessfully;
 2308        messageEntity.ProcessingError = hadErrors ? "Есть ошибки обработки вложений." : null;
 309
 2310        await _messageDataServices.UpdateInboxMessage(messageEntity);
 2311        _logger.LogInformation(
 2312            "Processed message {MessageExternalId}. Matches: {MatchedCount}, status: {Status}",
 2313            messageDto.MessageExternalId,
 2314            matchedCount,
 2315            messageEntity.ProcessingStatus);
 316
 2317        result.MessagesProcessed = 1;
 2318        return result;
 5319    }
 320
 321    /// <summary>
 322    /// Reason ограничен MaxLength(1000) на EmailAttachmentProcessingResult — номера обрезаются, чтобы влезть.
 323    /// </summary>
 324    private const int MaxReasonLength = 1000;
 325
 326    private static string BuildOrdersImportedReason(OrderImportResult importResult)
 327    {
 0328        var reason = $"Заказы импортированы напрямую ({importResult.MappingName}/{importResult.Delivery}): " +
 0329                     $"добавлено {importResult.AddedCount}, уже было в базе (пропущено) {importResult.SkippedExistingCou
 330
 0331        if (importResult.AddedOrderNumbers.Count > 0)
 332        {
 0333            reason += $" Номера: {string.Join(", ", importResult.AddedOrderNumbers)}.";
 334        }
 335
 0336        return reason.Length > MaxReasonLength
 0337            ? reason[..(MaxReasonLength - 1)] + "…"
 0338            : reason;
 339    }
 340
 341    private static bool IsAttachmentAlreadyStored(
 342        EmailMessageInbox messageEntity,
 343        string hash,
 344        InboxAttachmentDto attachmentDto) =>
 3345        messageEntity.Attachments.Any(x =>
 4346            x.Sha256Hash == hash &&
 4347            x.FileName == attachmentDto.FileName &&
 4348            x.FileSizeBytes == attachmentDto.FileSizeBytes);
 349}