Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.Core/ArchiveScanner.cs
T
redlineoandClaude Sonnet 5 804b9610ca Generalize mail templates from a fixed pair to an operator-managed list
ArchiveKind was a hardcoded two-value enum (ssh_access/config_update),
wired through FilenameParser's regex, MailTemplateSet's two fixed
slots, and SettingsForm's two fixed tabs. Operators wanted arbitrary
custom templates (own file-name suffix, subject, body) without a code
change per new archive kind ssh-access-tui might start producing.

Replaced with MailTemplateDefinition (DisplayName/FileSuffix/Subject/
BodyHtml) in an open-ended list: FilenameParser now accepts any
"{login}_{suffix}.7z" and ArchiveScanner matches the suffix against
the configured list; SendJob.Kind/SendLogEntry.Kind became plain
strings; SettingsForm gained a template list (+/- buttons, editor
panel) replacing the two static tabs. ssh_access/config_update are now
just the two default entries in that list, not special-cased.

Handles the two backward-compat edges that matter for a tool already
in real use: old config.json's object-shaped "templates" migrates to
the new list shape transparently on first load (DataContractJsonSerializer
turned out to silently produce an empty list rather than throw on the
old shape, so detection is structural, not exception-based), and old
send-log.csv rows with PascalCase Kind values (SshAccess/ConfigUpdate)
still dedupe correctly against the new snake_case suffixes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 11:26:31 +03:00

156 lines
8.1 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace BulkArchiveMailer.Core
{
/// <summary>Everything ArchiveScanner.Scan found in one folder, split by shape so the caller can render them however it likes.</summary>
public sealed class ScanResult
{
/// <summary>Ready / AlreadySent / Error rows - one per login that had at least one recognized *.7z file.</summary>
public List<SendJob> Jobs { get; } = new List<SendJob>();
/// <summary>One Skipped row per file that did not match the expected naming pattern.</summary>
public List<SendJob> UnrecognizedFiles { get; } = new List<SendJob>();
/// <summary>Convenience: both lists concatenated, sorted the same way PreviewForm wants to bind a single grid.</summary>
public IEnumerable<SendJob> AllRows => Jobs.Concat(UnrecognizedFiles);
}
/// <summary>
/// Walks a folder of ssh-access-tui output archives (plain System.IO -
/// no Outlook/COM involved) and builds the list of preview rows.
/// </summary>
public static class ArchiveScanner
{
/// <param name="folderPath">Folder to scan (non-recursive - ssh-access-tui writes archives flat into one output directory).</param>
/// <param name="emailDomain">Domain appended to the derived local part, e.g. "redlab.lo".</param>
/// <param name="templates">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.</param>
/// <param name="sendLog">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.</param>
public static ScanResult Scan(string folderPath, string emailDomain, List<MailTemplateDefinition> 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<string, List<(string Path, string Suffix)>>(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;
}
}
}