Files
redlineoandClaude Sonnet 5 6e823954a3 Fix hidden add/remove template buttons, add per-template login filter
The add/remove template buttons sat directly below the fixed-height
list with only 6px of margin to the tab's bottom edge - enough on this
dev machine but not on the operator's real one (different DPI/font
scale), so the buttons rendered off the visible area. Moved them above
the list instead (visibility no longer depends on how much space is
left at the bottom) and gave the whole "Шаблоны" tab substantially
more vertical room throughout.

Also added an optional per-template login regex filter
(MailTemplateDefinition.LoginPattern): once a file's suffix matches a
template, its login must also match that template's pattern (if any)
or the row surfaces as a clear Error in the preview instead of
silently going through - addresses "непонятно что будет работать".

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-24 12:23:54 +03:00

196 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
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 (!string.IsNullOrEmpty(matchedTemplate.LoginPattern) && !LoginMatchesPattern(parsed.Login, matchedTemplate.LoginPattern))
{
// Suffix matched, but the operator scoped this template
// to a subset of logins (e.g. a per-team/per-suffix
// custom template) and this file's login falls outside
// it - surfaced next to the other per-file
// "recognized but does not qualify" case (suffix
// mismatch above) rather than silently picking a
// different template or falling through to
// byLogin/Ready.
result.UnrecognizedFiles.Add(new SendJob
{
ArchivePath = filePath,
Status = SendStatus.Error,
ErrorMessage = $"Логин \"{parsed.Login}\" не соответствует фильтру шаблона \"{matchedTemplate.DisplayName}\" ({matchedTemplate.LoginPattern}): {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;
}
/// <summary>
/// Whether login satisfies the template's LoginPattern regex
/// (case-insensitive). An invalid pattern (e.g. hand-edited
/// config.json with broken regex syntax) is treated as "does not
/// match" rather than throwing and aborting the whole folder scan -
/// SettingsForm.btnSave_Click is where a bad pattern is meant to be
/// caught, before it ever reaches disk.
/// </summary>
private static bool LoginMatchesPattern(string login, string pattern)
{
try
{
return Regex.IsMatch(login, pattern, RegexOptions.IgnoreCase);
}
catch (ArgumentException)
{
return false;
}
}
}
}