using System; using System.Collections.Generic; using System.IO; using System.Linq; namespace BulkArchiveMailer.Core { /// Everything ArchiveScanner.Scan found in one folder, split by shape so the caller can render them however it likes. public sealed class ScanResult { /// Ready / AlreadySent / Error rows - one per login that had at least one recognized *.7z file. public List Jobs { get; } = new List(); /// One Skipped row per file that did not match the expected naming pattern. public List UnrecognizedFiles { get; } = new List(); /// Convenience: both lists concatenated, sorted the same way PreviewForm wants to bind a single grid. public IEnumerable AllRows => Jobs.Concat(UnrecognizedFiles); } /// /// Walks a folder of ssh-access-tui output archives (plain System.IO - /// no Outlook/COM involved) and builds the list of preview rows. /// public static class ArchiveScanner { /// Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory). /// Domain appended to the derived local part, e.g. "redlab.lo". /// Configured templates, matched against each parsed file's suffix (case-insensitively) to render that job's Subject/BodyHtml/KindDisplay. May be null or empty (every recognized file then goes to UnrecognizedFiles, since nothing can match), useful for pure filename-parsing tests. /// When provided, each single-archive login is checked against the log (by SHA-256 of the archive's current bytes) and marked AlreadySent if a prior Sent row matches. When null, dedup is skipped entirely and every recognized single-archive login comes back Ready. public static ScanResult Scan(string folderPath, string emailDomain, List templates, SendLogStore sendLog = null) { var result = new ScanResult(); if (string.IsNullOrWhiteSpace(folderPath) || !Directory.Exists(folderPath)) { // Caller (SettingsForm/PreviewForm) is expected to validate // the configured folder up front and show its own error; // scanning a missing folder just yields an empty result // rather than throwing. return result; } // login -> every (path, suffix) found for it, case-insensitive // because the file system this runs on (Windows) is too. var byLogin = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (var filePath in Directory.EnumerateFiles(folderPath, "*.7z", SearchOption.TopDirectoryOnly).OrderBy(p => p, StringComparer.OrdinalIgnoreCase)) { var fileName = Path.GetFileName(filePath); if (!FilenameParser.TryParse(fileName, out var parsed)) { result.UnrecognizedFiles.Add(new SendJob { ArchivePath = filePath, Status = SendStatus.Skipped, ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_{{суффикс}}.7z\": {fileName}", }); continue; } var matchedTemplate = templates?.FirstOrDefault(t => string.Equals(t.FileSuffix, parsed.Suffix, StringComparison.OrdinalIgnoreCase)); if (matchedTemplate == null) { result.UnrecognizedFiles.Add(new SendJob { ArchivePath = filePath, Status = SendStatus.Skipped, ErrorMessage = $"Суффикс \"{parsed.Suffix}\" не соответствует ни одному настроенному шаблону: {fileName}", }); continue; } if (!byLogin.TryGetValue(parsed.Login, out var list)) { list = new List<(string, string)>(); byLogin[parsed.Login] = list; } list.Add((filePath, parsed.Suffix)); } foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) { var login = kv.Key; var files = kv.Value; var email = EmailAddressBuilder.BuildEmail(login, emailDomain); if (files.Count > 1) { // Both archive kinds present for one login (or, less // likely, a same-kind duplicate left behind from a // prior export) - this is a data error upstream. Never // auto-pick one; surface it as a blocking row instead. var names = string.Join(", ", files.Select(f => Path.GetFileName(f.Path))); result.Jobs.Add(new SendJob { Login = login, Email = email, Kind = null, ArchivePath = null, ConflictingArchivePaths = files.Select(f => f.Path).ToList(), Status = SendStatus.Error, ErrorMessage = $"Для логина \"{login}\" одновременно найдено {files.Count} архива(ов): {names}. Это ошибка данных (должен существовать только один архив на логин за раз) — требуется ручная проверка на jump-хосте, автоматический выбор не выполняется.", }); continue; } var (path, suffix) = files[0]; // Guaranteed non-null: a suffix only ever makes it into // byLogin after already matching a template above. var matchedTemplate = templates.First(t => string.Equals(t.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase)); var values = TemplateEngine.BuildValues(login, email); var job = new SendJob { Login = login, Email = email, Kind = suffix, ArchivePath = path, Subject = TemplateEngine.Render(matchedTemplate.Subject, values), BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values), KindDisplay = matchedTemplate.DisplayName, Status = SendStatus.Ready, }; if (sendLog != null) { try { job.FileSha256 = SendLogStore.ComputeSha256(path); if (sendLog.TryFindSent(email, suffix, job.FileSha256, out var priorEntry)) { job.Status = SendStatus.AlreadySent; job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор)."; } } catch (IOException ex) { // File locked/unreadable right now - don't block // the whole scan over one bad file, surface it as // an error row instead. job.Status = SendStatus.Error; job.ErrorMessage = $"Не удалось прочитать файл архива для проверки дубликатов: {ex.Message}"; } } result.Jobs.Add(job); } return result; } } }