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:
1 parent
cb3b273741
commit
804b9610ca
16 files changed
+904
-288
No files matched your search
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
using System.Runtime.Serialization;
|
||||
using System.Runtime.Serialization.Json;
|
||||
@@ -7,9 +9,16 @@ using System.Text;
|
||||
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition.</summary>
|
||||
[DataContract]
|
||||
public sealed class TemplateData
|
||||
public sealed class MailTemplateData
|
||||
{
|
||||
[DataMember(Name = "displayName")]
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "fileSuffix")]
|
||||
public string FileSuffix { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
@@ -17,16 +26,6 @@ namespace BulkArchiveMailer.Core
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
public sealed class TemplateSetData
|
||||
{
|
||||
[DataMember(Name = "ssh_access")]
|
||||
public TemplateData SshAccess { get; set; } = new TemplateData();
|
||||
|
||||
[DataMember(Name = "config_update")]
|
||||
public TemplateData ConfigUpdate { get; set; } = new TemplateData();
|
||||
}
|
||||
|
||||
/// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary>
|
||||
[DataContract]
|
||||
public sealed class AppSettingsData
|
||||
@@ -70,38 +69,57 @@ namespace BulkArchiveMailer.Core
|
||||
[DataMember(Name = "fromAccountSmtpAddress")]
|
||||
public string FromAccountSmtpAddress { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Arbitrary-length list of configured templates - replaces the old
|
||||
/// fixed ssh_access/config_update pair. Each entry's FileSuffix is
|
||||
/// expected to be non-empty and unique among the list; enforcing
|
||||
/// that is SettingsForm's job at save time (see its btnSave_Click),
|
||||
/// not this class's.
|
||||
/// </summary>
|
||||
[DataMember(Name = "templates")]
|
||||
public TemplateSetData Templates { get; set; } = new TemplateSetData();
|
||||
public List<MailTemplateData> Templates { get; set; } = new List<MailTemplateData>();
|
||||
|
||||
public MailTemplateSet ToTemplateSet()
|
||||
public List<MailTemplateDefinition> ToTemplateList()
|
||||
{
|
||||
return new MailTemplateSet
|
||||
var list = new List<MailTemplateDefinition>();
|
||||
if (Templates == null)
|
||||
{
|
||||
SshAccess = new MailTemplate
|
||||
return list;
|
||||
}
|
||||
|
||||
foreach (var t in Templates)
|
||||
{
|
||||
list.Add(new MailTemplateDefinition
|
||||
{
|
||||
Subject = Templates?.SshAccess?.Subject ?? "",
|
||||
BodyHtml = Templates?.SshAccess?.BodyHtml ?? "",
|
||||
},
|
||||
ConfigUpdate = new MailTemplate
|
||||
{
|
||||
Subject = Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
},
|
||||
};
|
||||
DisplayName = t?.DisplayName ?? "",
|
||||
FileSuffix = t?.FileSuffix ?? "",
|
||||
Subject = t?.Subject ?? "",
|
||||
BodyHtml = t?.BodyHtml ?? "",
|
||||
});
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
|
||||
public void SetTemplate(ArchiveKind kind, MailTemplate template)
|
||||
/// <summary>Replaces the whole template list - used by SettingsForm's Save, which always writes back the entire in-memory list rather than one slot at a time (there are no fixed slots anymore).</summary>
|
||||
public void SetTemplates(List<MailTemplateDefinition> templates)
|
||||
{
|
||||
Templates = Templates ?? new TemplateSetData();
|
||||
var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" };
|
||||
if (kind == ArchiveKind.SshAccess)
|
||||
var list = new List<MailTemplateData>();
|
||||
if (templates != null)
|
||||
{
|
||||
Templates.SshAccess = data;
|
||||
}
|
||||
else
|
||||
{
|
||||
Templates.ConfigUpdate = data;
|
||||
foreach (var t in templates)
|
||||
{
|
||||
list.Add(new MailTemplateData
|
||||
{
|
||||
DisplayName = t?.DisplayName ?? "",
|
||||
FileSuffix = t?.FileSuffix ?? "",
|
||||
Subject = t?.Subject ?? "",
|
||||
BodyHtml = t?.BodyHtml ?? "",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Templates = list;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +144,57 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json";
|
||||
|
||||
/// <summary>
|
||||
/// Shape of a config.json written by versions v1.0.0-v1.0.4, before
|
||||
/// ArchiveKind/the fixed ssh_access+config_update pair was replaced
|
||||
/// by an open-ended template list - back then "templates" was a
|
||||
/// JSON OBJECT with two named keys, not an array. Private/nested:
|
||||
/// only ever used as a one-time fallback parse target inside
|
||||
/// Load()'s migration handling below, never exposed outside this
|
||||
/// file.
|
||||
/// </summary>
|
||||
[DataContract]
|
||||
private sealed class LegacyTemplateData
|
||||
{
|
||||
[DataMember(Name = "subject")]
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "bodyHtml")]
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
private sealed class LegacyTemplateSetData
|
||||
{
|
||||
[DataMember(Name = "ssh_access")]
|
||||
public LegacyTemplateData SshAccess { get; set; } = new LegacyTemplateData();
|
||||
|
||||
[DataMember(Name = "config_update")]
|
||||
public LegacyTemplateData ConfigUpdate { get; set; } = new LegacyTemplateData();
|
||||
}
|
||||
|
||||
[DataContract]
|
||||
private sealed class LegacyAppSettingsData
|
||||
{
|
||||
[DataMember(Name = "archiveFolder")]
|
||||
public string ArchiveFolder { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "emailDomain")]
|
||||
public string EmailDomain { get; set; } = "redlab.lo";
|
||||
|
||||
[DataMember(Name = "testModeDefault")]
|
||||
public bool TestModeDefault { get; set; } = true;
|
||||
|
||||
[DataMember(Name = "signatureHtmlPath")]
|
||||
public string SignatureHtmlPath { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "fromAccountSmtpAddress")]
|
||||
public string FromAccountSmtpAddress { get; set; } = "";
|
||||
|
||||
[DataMember(Name = "templates")]
|
||||
public LegacyTemplateSetData Templates { get; set; } = new LegacyTemplateSetData();
|
||||
}
|
||||
|
||||
public static string ConfigDirectory =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "BulkArchiveMailer");
|
||||
|
||||
@@ -158,6 +227,14 @@ namespace BulkArchiveMailer.Core
|
||||
/// never silently overwritten - the DataContractJsonSerializer
|
||||
/// exception propagates so the caller can warn the operator
|
||||
/// instead of quietly discarding their edits.
|
||||
///
|
||||
/// If the file on disk turns out to be in the pre-v1.0.5 legacy
|
||||
/// shape (object "templates" instead of a list - see
|
||||
/// LegacyAppSettingsData), Deserialize below transparently
|
||||
/// migrates it in memory; this method additionally re-persists the
|
||||
/// migrated result immediately (Save), so every later Load() goes
|
||||
/// through the fast/primary parse path instead of re-migrating on
|
||||
/// every single load.
|
||||
/// </summary>
|
||||
public static AppSettingsData Load()
|
||||
{
|
||||
@@ -168,7 +245,14 @@ namespace BulkArchiveMailer.Core
|
||||
}
|
||||
|
||||
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8);
|
||||
return Deserialize(json);
|
||||
var data = DeserializeWithLegacyFallback(json, out var wasLegacyMigration);
|
||||
|
||||
if (wasLegacyMigration)
|
||||
{
|
||||
Save(data);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
public static void Save(AppSettingsData data)
|
||||
@@ -182,16 +266,134 @@ namespace BulkArchiveMailer.Core
|
||||
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
|
||||
}
|
||||
|
||||
private static AppSettingsData Deserialize(string json)
|
||||
/// <summary>
|
||||
/// Pure (no disk I/O) parse of a config.json's text into
|
||||
/// AppSettingsData. DataContractJsonSerializer turns out to be far
|
||||
/// too lenient to detect the legacy shape by catching an exception
|
||||
/// from a straight `ReadObject(typeof(AppSettingsData))` call -
|
||||
/// given the old object-shaped "templates", it does NOT throw, it
|
||||
/// just silently produces an EMPTY List<MailTemplateData>
|
||||
/// (confirmed while writing the migration test for this method -
|
||||
/// see AppSettingsTests). So detecting "is this the legacy shape"
|
||||
/// has to happen explicitly, before deciding which type to parse
|
||||
/// into - see IsLegacyTemplatesShape below, which inspects the
|
||||
/// JSON structurally (via the same JsonReaderWriterFactory
|
||||
/// DataContractJsonSerializer itself uses under the hood) rather
|
||||
/// than relying on a parse exception that never comes.
|
||||
/// </summary>
|
||||
private static AppSettingsData Deserialize(string json) => DeserializeWithLegacyFallback(json, out _);
|
||||
|
||||
private static AppSettingsData DeserializeWithLegacyFallback(string json, out bool wasLegacyMigration)
|
||||
{
|
||||
wasLegacyMigration = false;
|
||||
|
||||
if (IsLegacyTemplatesShape(json))
|
||||
{
|
||||
try
|
||||
{
|
||||
var legacy = DeserializeAs<LegacyAppSettingsData>(json);
|
||||
wasLegacyMigration = true;
|
||||
return ConvertLegacyToCurrent(legacy);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Looked legacy-shaped ("templates" is a JSON object)
|
||||
// but doesn't actually parse as LegacyAppSettingsData
|
||||
// either - fall through to the normal parse attempt
|
||||
// below and let ITS exception (if any) describe what's
|
||||
// actually wrong, rather than masking it with a legacy
|
||||
// parse failure that was never the real problem.
|
||||
}
|
||||
}
|
||||
|
||||
return DeserializeAs<AppSettingsData>(json);
|
||||
}
|
||||
|
||||
private static T DeserializeAs<T>(string json)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json);
|
||||
using (var ms = new MemoryStream(bytes))
|
||||
{
|
||||
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
|
||||
return (AppSettingsData)serializer.ReadObject(ms);
|
||||
var serializer = new DataContractJsonSerializer(typeof(T));
|
||||
return (T)serializer.ReadObject(ms);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the top-level "templates" member of this JSON text is a
|
||||
/// JSON OBJECT (the pre-v1.0.5 shape: {"ssh_access": {...},
|
||||
/// "config_update": {...}}) rather than a JSON ARRAY (the current
|
||||
/// List<MailTemplateData> shape). Inspects the JSON via
|
||||
/// JsonReaderWriterFactory/XElement - the same JSON-to-XML mapping
|
||||
/// DataContractJsonSerializer itself is built on - specifically so
|
||||
/// this check reflects the real wire shape rather than a hand-rolled
|
||||
/// regex/text scan. Returns false (never throws) for anything that
|
||||
/// isn't even parseable as JSON at all; DeserializeWithLegacyFallback's
|
||||
/// normal AppSettingsData parse attempt is what surfaces a
|
||||
/// genuinely malformed file's exception to the caller.
|
||||
/// </summary>
|
||||
private static bool IsLegacyTemplatesShape(string json)
|
||||
{
|
||||
try
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(json ?? "");
|
||||
using (var reader = JsonReaderWriterFactory.CreateJsonReader(bytes, System.Xml.XmlDictionaryReaderQuotas.Max))
|
||||
{
|
||||
var root = System.Xml.Linq.XElement.Load(reader);
|
||||
var templatesElement = root.Element("templates");
|
||||
var typeAttr = templatesElement?.Attribute("type");
|
||||
return typeAttr != null && string.Equals(typeAttr.Value, "object", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Converts a pre-v1.0.5 config.json (ArchiveKind-based, exactly
|
||||
/// two named templates) into the current shape: two list entries
|
||||
/// with FileSuffix "ssh_access"/"config_update" (same text the old
|
||||
/// JSON keys already used) carrying over the operator's saved
|
||||
/// Subject/BodyHtml. DisplayName did not exist in the old shape, so
|
||||
/// it is filled in from the embedded defaults instead.
|
||||
/// </summary>
|
||||
private static AppSettingsData ConvertLegacyToCurrent(LegacyAppSettingsData legacy)
|
||||
{
|
||||
var defaults = LoadDefaults();
|
||||
string DefaultDisplayName(string suffix) =>
|
||||
defaults.Templates?.FirstOrDefault(t => string.Equals(t?.FileSuffix, suffix, StringComparison.OrdinalIgnoreCase))?.DisplayName ?? "";
|
||||
|
||||
var migrated = new AppSettingsData
|
||||
{
|
||||
ArchiveFolder = legacy?.ArchiveFolder ?? "",
|
||||
EmailDomain = string.IsNullOrEmpty(legacy?.EmailDomain) ? "redlab.lo" : legacy.EmailDomain,
|
||||
TestModeDefault = legacy?.TestModeDefault ?? true,
|
||||
SignatureHtmlPath = legacy?.SignatureHtmlPath ?? "",
|
||||
FromAccountSmtpAddress = legacy?.FromAccountSmtpAddress ?? "",
|
||||
Templates = new List<MailTemplateData>
|
||||
{
|
||||
new MailTemplateData
|
||||
{
|
||||
DisplayName = DefaultDisplayName("ssh_access"),
|
||||
FileSuffix = "ssh_access",
|
||||
Subject = legacy?.Templates?.SshAccess?.Subject ?? "",
|
||||
BodyHtml = legacy?.Templates?.SshAccess?.BodyHtml ?? "",
|
||||
},
|
||||
new MailTemplateData
|
||||
{
|
||||
DisplayName = DefaultDisplayName("config_update"),
|
||||
FileSuffix = "config_update",
|
||||
Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "",
|
||||
BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return migrated;
|
||||
}
|
||||
|
||||
private static string Serialize(AppSettingsData data)
|
||||
{
|
||||
using (var ms = new MemoryStream())
|
||||
|
||||
@@ -26,9 +26,9 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <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">Used to render each Ready job's Subject/BodyHtml. May be null (rows are then created with empty Subject/BodyHtml, useful for pure filename-parsing tests).</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, MailTemplateSet templates, SendLogStore sendLog = null)
|
||||
public static ScanResult Scan(string folderPath, string emailDomain, List<MailTemplateDefinition> templates, SendLogStore sendLog = null)
|
||||
{
|
||||
var result = new ScanResult();
|
||||
|
||||
@@ -41,9 +41,9 @@ namespace BulkArchiveMailer.Core
|
||||
return result;
|
||||
}
|
||||
|
||||
// login -> every (path, kind) found for it, case-insensitive
|
||||
// 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, ArchiveKind Kind)>>(StringComparer.OrdinalIgnoreCase);
|
||||
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))
|
||||
{
|
||||
@@ -55,18 +55,30 @@ namespace BulkArchiveMailer.Core
|
||||
{
|
||||
ArchivePath = filePath,
|
||||
Status = SendStatus.Skipped,
|
||||
ErrorMessage = $"Имя файла не соответствует шаблону \"{{login}}_ssh_access.7z\" / \"{{login}}_config_update.7z\": {fileName}",
|
||||
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, ArchiveKind)>();
|
||||
list = new List<(string, string)>();
|
||||
byLogin[parsed.Login] = list;
|
||||
}
|
||||
|
||||
list.Add((filePath, parsed.Kind));
|
||||
list.Add((filePath, parsed.Suffix));
|
||||
}
|
||||
|
||||
foreach (var kv in byLogin.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
|
||||
@@ -95,19 +107,21 @@ namespace BulkArchiveMailer.Core
|
||||
continue;
|
||||
}
|
||||
|
||||
var (path, kind) = files[0];
|
||||
var rendered = templates != null
|
||||
? TemplateEngine.Render(templates.Get(kind), login, email)
|
||||
: new MailTemplate();
|
||||
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 = kind,
|
||||
Kind = suffix,
|
||||
ArchivePath = path,
|
||||
Subject = rendered.Subject,
|
||||
BodyHtml = rendered.BodyHtml,
|
||||
Subject = TemplateEngine.Render(matchedTemplate.Subject, values),
|
||||
BodyHtml = TemplateEngine.Render(matchedTemplate.BodyHtml, values),
|
||||
KindDisplay = matchedTemplate.DisplayName,
|
||||
Status = SendStatus.Ready,
|
||||
};
|
||||
|
||||
@@ -116,7 +130,7 @@ namespace BulkArchiveMailer.Core
|
||||
try
|
||||
{
|
||||
job.FileSha256 = SendLogStore.ComputeSha256(path);
|
||||
if (sendLog.TryFindSent(email, kind, job.FileSha256, out var priorEntry))
|
||||
if (sendLog.TryFindSent(email, suffix, job.FileSha256, out var priorEntry))
|
||||
{
|
||||
job.Status = SendStatus.AlreadySent;
|
||||
job.ErrorMessage = $"Уже отправлено {priorEntry.TimestampUtc:yyyy-MM-dd HH:mm} UTC (тот же файл, без изменений с тех пор).";
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
<Compile Include="ArchiveScanner.cs" />
|
||||
<Compile Include="EmailAddressBuilder.cs" />
|
||||
<Compile Include="FilenameParser.cs" />
|
||||
<Compile Include="MailTemplates.cs" />
|
||||
<Compile Include="MailTemplateDefinition.cs" />
|
||||
<Compile Include="SendJob.cs" />
|
||||
<Compile Include="SendLogStore.cs" />
|
||||
<Compile Include="TemplateEngine.cs" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// One configured mail template: which files it applies to (by file
|
||||
/// name suffix, "{login}_{FileSuffix}.7z"), plus its Subject/BodyHtml.
|
||||
/// Replaces the old fixed pair of named templates (ssh_access /
|
||||
/// config_update) with an arbitrary, operator-editable list -
|
||||
/// AppSettingsData.Templates now holds a List<MailTemplateDefinition>
|
||||
/// instead of two hard-coded slots.
|
||||
///
|
||||
/// FileSuffix is the only identifier a template needs: it must be
|
||||
/// non-empty and unique (case-insensitively) among all configured
|
||||
/// templates for ArchiveScanner's suffix-to-template lookup to be
|
||||
/// unambiguous. Enforcing that (non-empty + unique) is SettingsForm's
|
||||
/// job at save time, not this class's - a plain data holder has no
|
||||
/// validation of its own.
|
||||
/// </summary>
|
||||
public sealed class MailTemplateDefinition
|
||||
{
|
||||
public string DisplayName { get; set; } = "";
|
||||
|
||||
/// <summary>
|
||||
/// Suffix of the file name this template renders for: matches
|
||||
/// files named "{login}_{FileSuffix}.7z" (case-insensitive; see
|
||||
/// FilenameParser/ArchiveScanner). Must be non-empty and unique
|
||||
/// among all configured templates - see class remarks.
|
||||
/// </summary>
|
||||
public string FileSuffix { get; set; } = "";
|
||||
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
namespace BulkArchiveMailer.Core
|
||||
{
|
||||
/// <summary>One editable Subject + BodyHtml pair.</summary>
|
||||
public sealed class MailTemplate
|
||||
{
|
||||
public string Subject { get; set; } = "";
|
||||
|
||||
public string BodyHtml { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>The two named template keys used throughout the tool (matches ArchiveKind, and the JSON config's "templates" object).</summary>
|
||||
public static class MailTemplateKeys
|
||||
{
|
||||
public const string SshAccess = "ssh_access";
|
||||
public const string ConfigUpdate = "config_update";
|
||||
|
||||
public static string For(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate;
|
||||
}
|
||||
|
||||
/// <summary>Both named templates together, as used by ArchiveScanner to render a SendJob's Subject/BodyHtml.</summary>
|
||||
public sealed class MailTemplateSet
|
||||
{
|
||||
public MailTemplate SshAccess { get; set; } = new MailTemplate();
|
||||
|
||||
public MailTemplate ConfigUpdate { get; set; } = new MailTemplate();
|
||||
|
||||
public MailTemplate Get(ArchiveKind kind) => kind == ArchiveKind.SshAccess ? SshAccess : ConfigUpdate;
|
||||
|
||||
/// <summary>
|
||||
/// The two templates as shipped in config.default.json (embedded
|
||||
/// resource), independent of anything the user has saved to
|
||||
/// %APPDATA%\BulkArchiveMailer\config.json. Mainly useful for a
|
||||
/// "reset to defaults" action in SettingsForm.
|
||||
/// </summary>
|
||||
public static MailTemplateSet LoadDefaults() => AppSettings.LoadDefaults().ToTemplateSet();
|
||||
}
|
||||
}
|
||||
@@ -29,8 +29,8 @@ namespace BulkArchiveMailer.Core
|
||||
|
||||
public string Email { get; set; } = "";
|
||||
|
||||
/// <summary>Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows.</summary>
|
||||
public ArchiveKind? Kind { get; set; }
|
||||
/// <summary>File suffix ("ssh_access", "config_update", or any other configured template's FileSuffix). Null for Error rows caused by a login/kind conflict, and for Skipped (unrecognized file) rows.</summary>
|
||||
public string Kind { get; set; }
|
||||
|
||||
/// <summary>Null for conflict Error rows (see <see cref="ConflictingArchivePaths"/>) - otherwise the single archive to attach.</summary>
|
||||
public string ArchivePath { get; set; }
|
||||
@@ -55,21 +55,14 @@ namespace BulkArchiveMailer.Core
|
||||
|
||||
public string FileName => string.IsNullOrEmpty(ArchivePath) ? "" : Path.GetFileName(ArchivePath);
|
||||
|
||||
public string KindDisplay
|
||||
{
|
||||
get
|
||||
{
|
||||
switch (Kind)
|
||||
{
|
||||
case ArchiveKind.SshAccess:
|
||||
return "Новый доступ (ключ + конфиг)";
|
||||
case ArchiveKind.ConfigUpdate:
|
||||
return "Обновление конфига";
|
||||
default:
|
||||
return "-";
|
||||
}
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// Human-readable label for the matched template (its
|
||||
/// DisplayName), explicitly set by ArchiveScanner when it renders
|
||||
/// this job - "-" for conflict Error rows and Skipped rows, which
|
||||
/// never had a matched template. No longer computed from a fixed
|
||||
/// enum switch, since the set of templates is now open-ended.
|
||||
/// </summary>
|
||||
public string KindDisplay { get; set; } = "-";
|
||||
|
||||
public override string ToString() => $"{Login} <{Email}> [{Status}]";
|
||||
}
|
||||
|
||||
@@ -22,7 +22,10 @@ namespace BulkArchiveMailer.Core
|
||||
public DateTime TimestampUtc { get; set; }
|
||||
public string Login { get; set; } = "";
|
||||
public string Email { get; set; } = "";
|
||||
public ArchiveKind Kind { get; set; }
|
||||
|
||||
/// <summary>File suffix ("ssh_access", "config_update", or any other configured template's FileSuffix) - see the normalization note on SendLogStore.ReadAll for the legacy PascalCase values (e.g. "SshAccess") written by versions v1.0.0-v1.0.4.</summary>
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
public string ArchivePath { get; set; } = "";
|
||||
public string FileSha256 { get; set; } = "";
|
||||
public string Status { get; set; } = "";
|
||||
@@ -109,8 +112,7 @@ namespace BulkArchiveMailer.Core
|
||||
continue; // malformed/truncated row - skip rather than throw
|
||||
}
|
||||
|
||||
ArchiveKind kind;
|
||||
Enum.TryParse(fields[3], true, out kind);
|
||||
var kind = NormalizeLegacyKind(fields[3]);
|
||||
|
||||
DateTime ts;
|
||||
DateTime.TryParse(
|
||||
@@ -136,6 +138,32 @@ namespace BulkArchiveMailer.Core
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps the legacy PascalCase Kind values written by v1.0.0-v1.0.4
|
||||
/// (ArchiveKind.ToString(): "SshAccess"/"ConfigUpdate") to the
|
||||
/// snake_case FileSuffix text used everywhere since ArchiveKind was
|
||||
/// replaced by a free-form template list ("ssh_access"/
|
||||
/// "config_update" - same text as config.default.json's
|
||||
/// "fileSuffix"). Anything else (already-current suffix, or a
|
||||
/// custom suffix that never existed under the old enum) passes
|
||||
/// through unchanged, so TryFindSent's dedup lookup keeps matching
|
||||
/// against send-log.csv rows written before this migration.
|
||||
/// </summary>
|
||||
private static string NormalizeLegacyKind(string rawKind)
|
||||
{
|
||||
if (string.Equals(rawKind, "SshAccess", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "ssh_access";
|
||||
}
|
||||
|
||||
if (string.Equals(rawKind, "ConfigUpdate", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return "config_update";
|
||||
}
|
||||
|
||||
return rawKind;
|
||||
}
|
||||
|
||||
public void Append(SendLogEntry entry)
|
||||
{
|
||||
if (entry == null)
|
||||
@@ -162,7 +190,7 @@ namespace BulkArchiveMailer.Core
|
||||
Escape(entry.TimestampUtc.ToString("o", CultureInfo.InvariantCulture)),
|
||||
Escape(entry.Login),
|
||||
Escape(entry.Email),
|
||||
Escape(entry.Kind.ToString()),
|
||||
Escape(entry.Kind ?? ""),
|
||||
Escape(entry.ArchivePath),
|
||||
Escape(entry.FileSha256),
|
||||
Escape(entry.Status),
|
||||
@@ -176,7 +204,7 @@ namespace BulkArchiveMailer.Core
|
||||
/// has Status=Sent. Draft/Failed rows never count as "already
|
||||
/// sent" - only a completed real Send does.
|
||||
/// </summary>
|
||||
public bool TryFindSent(string email, ArchiveKind kind, string sha256, out SendLogEntry match)
|
||||
public bool TryFindSent(string email, string kind, string sha256, out SendLogEntry match)
|
||||
{
|
||||
match = null;
|
||||
if (string.IsNullOrEmpty(sha256) || string.IsNullOrEmpty(email))
|
||||
@@ -196,7 +224,7 @@ namespace BulkArchiveMailer.Core
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.Kind != kind)
|
||||
if (!string.Equals(entry.Kind, kind, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -42,21 +42,15 @@ namespace BulkArchiveMailer.Core
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Renders both Subject and BodyHtml of a template for one recipient.</summary>
|
||||
public static MailTemplate Render(MailTemplate template, string login, string email)
|
||||
/// <summary>Builds the {Login}/{Email}/{Date} placeholder values for one recipient, ready to pass to Render(string, IDictionary<string,string>) for a template's Subject and BodyHtml separately.</summary>
|
||||
public static Dictionary<string, string> BuildValues(string login, string email)
|
||||
{
|
||||
var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["Login"] = login ?? "",
|
||||
["Email"] = email ?? "",
|
||||
["Date"] = DateTime.Now.ToString("dd.MM.yyyy"),
|
||||
};
|
||||
|
||||
return new MailTemplate
|
||||
{
|
||||
Subject = Render(template?.Subject ?? "", values),
|
||||
BodyHtml = Render(template?.BodyHtml ?? "", values),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,14 +4,18 @@
|
||||
"testModeDefault": true,
|
||||
"signatureHtmlPath": "",
|
||||
"fromAccountSmtpAddress": "",
|
||||
"templates": {
|
||||
"ssh_access": {
|
||||
"templates": [
|
||||
{
|
||||
"displayName": "Новый доступ (ключ + конфиг)",
|
||||
"fileSuffix": "ssh_access",
|
||||
"subject": "Доступ к SSH jump-хосту — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Во вложении — архив с вашим персональным SSH-ключом, готовым клиентским конфигом и краткой инструкцией по подключению (<b>{Login}_ssh_access.7z</b>).</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p><p>Если по какой-то причине не получается распаковать архив или подключиться — напишите в ответ на это письмо.</p>"
|
||||
},
|
||||
"config_update": {
|
||||
{
|
||||
"displayName": "Обновление конфига",
|
||||
"fileSuffix": "config_update",
|
||||
"subject": "Обновление конфигурации доступа — {Login}",
|
||||
"bodyHtml": "<p>Здравствуйте!</p><p>Список доступных вам через jump-хост сервисов изменился. Во вложении — обновлённый клиентский конфиг и инструкция (<b>{Login}_config_update.7z</b>). Ваш существующий SSH-ключ по-прежнему действителен, переустанавливать его не нужно — достаточно заменить конфиг.</p><p>Пароль от архива будет отправлен отдельным сообщением — не пересылайте архив вместе с паролем.</p>"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user