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>
This commit is contained in:
redlineoandClaude Sonnet 5 committed 2026-07-24 11:26:31 +03:00
1 parent cb3b273741
commit 804b9610ca
16 files changed
+904 -288

No files matched your search

@@ -3,51 +3,54 @@ using System.Text.RegularExpressions;
namespace BulkArchiveMailer.Core
{
/// <summary>
/// Which kind of archive ssh-access-tui produced. See
/// internal/keymgr/pack.go (BuildUserArchive -> "_ssh_access.7z") and
/// internal/keymgr/configonly.go (BuildConfigOnlyArchive ->
/// "_config_update.7z") in the main repo for the producing side.
/// </summary>
public enum ArchiveKind
{
SshAccess,
ConfigUpdate
}
/// <summary>Result of successfully parsing an archive file name.</summary>
public sealed class ParsedFilename
{
public ParsedFilename(string login, ArchiveKind kind)
public ParsedFilename(string login, string suffix)
{
Login = login;
Kind = kind;
Suffix = suffix;
}
/// <summary>Lower-cased login exactly as it appears in the file name (hyphens intact).</summary>
public string Login { get; }
public ArchiveKind Kind { get; }
/// <summary>
/// Lower-cased suffix exactly as it appears in the file name (the
/// part between the login and ".7z"), e.g. "ssh_access" or
/// "config_update" for the two built-in templates, or whatever
/// FileSuffix a custom template in AppSettingsData.Templates uses.
/// Whether this suffix corresponds to any *configured* template is
/// NOT checked here - that is ArchiveScanner's job, since it is the
/// one holding the list of templates.
/// </summary>
public string Suffix { get; }
}
/// <summary>
/// Parses the two file name shapes ssh-access-tui ever produces:
/// "{login}_ssh_access.7z" and "{login}_config_update.7z". Anything
/// else (wrong extension, extra suffix, typo'd kind, stray file dropped
/// in the export folder by a human) simply fails to parse - the caller
/// Parses the generic "{login}_{suffix}.7z" file name shape
/// ssh-access-tui produces (see internal/keymgr/pack.go and
/// internal/keymgr/configonly.go in the main repo for the producing
/// side, and config.default.json/SettingsForm for how an operator adds
/// further suffixes via custom templates). Anything else (wrong
/// extension, no underscore-separated suffix, stray file dropped in the
/// export folder by a human) simply fails to parse - the caller
/// (ArchiveScanner) is responsible for routing that into the
/// "unrecognized" bucket instead of blocking the batch.
/// "unrecognized" bucket instead of blocking the batch. Whether a
/// syntactically valid suffix actually matches a configured template is
/// also ArchiveScanner's job, not this parser's.
/// </summary>
public static class FilenameParser
{
// Matches the login part ssh-access-tui itself accepts for a Linux
// username: lowercase alnum segments joined by single hyphens (no
// leading/trailing/double hyphens). Case-insensitive purely as a
// defensive measure in case a file was renamed by hand on a
// case-insensitive filesystem; the captured login is normalized to
// lower case before use.
// leading/trailing/double hyphens), followed by an underscore and
// any lowercase alnum/underscore suffix. Case-insensitive purely as
// a defensive measure in case a file was renamed by hand on a
// case-insensitive filesystem; the captured login/suffix are
// normalized to lower case before use.
private static readonly Regex Pattern = new Regex(
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<kind>ssh_access|config_update)\.7z$",
@"^(?<login>[a-z0-9]+(?:-[a-z0-9]+)*)_(?<suffix>[a-z0-9_]+)\.7z$",
RegexOptions.IgnoreCase | RegexOptions.Compiled);
public static bool TryParse(string fileName, out ParsedFilename parsed)
@@ -65,10 +68,9 @@ namespace BulkArchiveMailer.Core
}
var login = match.Groups["login"].Value.ToLowerInvariant();
var kindText = match.Groups["kind"].Value.ToLowerInvariant();
var kind = kindText == "ssh_access" ? ArchiveKind.SshAccess : ArchiveKind.ConfigUpdate;
var suffix = match.Groups["suffix"].Value.ToLowerInvariant();
parsed = new ParsedFilename(login, kind);
parsed = new ParsedFilename(login, suffix);
return true;
}
}