Files
ssh-access-tui/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs
T
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

500 lines
21 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.Serialization;
using System.Runtime.Serialization.Json;
using System.Text;
namespace BulkArchiveMailer.Core
{
/// <summary>One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition.</summary>
[DataContract]
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; } = "";
[DataMember(Name = "bodyHtml")]
public string BodyHtml { get; set; } = "";
/// <summary>See MailTemplateDefinition.LoginPattern. Absent from any config.json written before v1.2.0 - DataContractJsonSerializer leaves this null (not "") for those, same caveat as every other string member here; ToTemplateList/SetTemplates and every call site normalize via `?? ""`.</summary>
[DataMember(Name = "loginPattern")]
public string LoginPattern { get; set; } = "";
}
/// <summary>Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it).</summary>
[DataContract]
public sealed class AppSettingsData
{
[DataMember(Name = "archiveFolder")]
public string ArchiveFolder { get; set; } = "";
[DataMember(Name = "emailDomain")]
public string EmailDomain { get; set; } = "redlab.lo";
/// <summary>Whether "test mode" (save to Drafts instead of Send) should be pre-checked when PreviewForm opens. Defaults to true - sending live is an explicit opt-in each time the operator unchecks it.</summary>
[DataMember(Name = "testModeDefault")]
public bool TestModeDefault { get; set; } = true;
/// <summary>Manual override for the Outlook signature .htm path, used when SignatureReader's registry-based auto-detection fails or the operator wants a different signature. Empty = rely on auto-detection.</summary>
[DataMember(Name = "signatureHtmlPath")]
public string SignatureHtmlPath { get; set; } = "";
/// <summary>
/// SmtpAddress of the Outlook.Account (from the current session's
/// Session.Accounts) that outgoing mail should be sent from, e.g.
/// a shared/service mailbox distinct from the operator's own
/// primary account. Empty (or null - see note below) means "use
/// whatever account Outlook picks by default", the previous,
/// unconfigurable behavior (SendUsingAccount left unset).
///
/// Loading a config.json written before this field existed does
/// NOT throw - DataContractJsonSerializer happily ignores unknown
/// absence - but it also does NOT fall back to the "" default
/// above: DataContractJsonSerializer builds the target object via
/// FormatterServices.GetUninitializedObject, which skips
/// constructors *and* field/property initializers entirely, so a
/// member never touched during deserialization keeps the CLR
/// default instead (null for string). This is true of every
/// string field on this class, not just this one - every call
/// site that reads FromAccountSmtpAddress must treat null the
/// same as "", which MailSender/SettingsForm already do via
/// `?? ""` / string.IsNullOrEmpty rather than relying on this
/// initializer.
/// </summary>
[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 List<MailTemplateData> Templates { get; set; } = new List<MailTemplateData>();
public List<MailTemplateDefinition> ToTemplateList()
{
var list = new List<MailTemplateDefinition>();
if (Templates == null)
{
return list;
}
foreach (var t in Templates)
{
list.Add(new MailTemplateDefinition
{
DisplayName = t?.DisplayName ?? "",
FileSuffix = t?.FileSuffix ?? "",
Subject = t?.Subject ?? "",
BodyHtml = t?.BodyHtml ?? "",
LoginPattern = t?.LoginPattern ?? "",
});
}
return list;
}
/// <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)
{
var list = new List<MailTemplateData>();
if (templates != null)
{
foreach (var t in templates)
{
list.Add(new MailTemplateData
{
DisplayName = t?.DisplayName ?? "",
FileSuffix = t?.FileSuffix ?? "",
Subject = t?.Subject ?? "",
BodyHtml = t?.BodyHtml ?? "",
LoginPattern = t?.LoginPattern ?? "",
});
}
}
Templates = list;
}
}
/// <summary>
/// Loads/saves the small JSON settings file at
/// %APPDATA%\BulkArchiveMailer\config.json. On first run (file
/// missing) the file is seeded verbatim from the config.default.json
/// resource embedded in this assembly, so the shipped defaults and the
/// initial user-editable file always start out identical - there is
/// only one place (config.default.json) where default copy/domain
/// text is authored.
///
/// Uses DataContractJsonSerializer (part of the .NET Framework since
/// 3.5, ships in System.Runtime.Serialization.dll) rather than a
/// third-party JSON package, specifically so this project has zero
/// NuGet dependencies to resolve - this whole solution cannot be
/// build-tested in the environment this was authored in, so removing
/// every avoidable source of "does the package restore correctly on
/// first open" risk is worth the small amount of extra code below.
/// </summary>
public static class AppSettings
{
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");
public static string ConfigFilePath => Path.Combine(ConfigDirectory, "config.json");
public static string ReadEmbeddedDefaultJson()
{
var asm = Assembly.GetExecutingAssembly();
using (var stream = asm.GetManifestResourceStream(EmbeddedDefaultResourceName))
{
if (stream == null)
{
throw new InvalidOperationException(
$"Embedded resource '{EmbeddedDefaultResourceName}' not found in {asm.FullName}. " +
"Check that config.default.json's Build Action is set to Embedded Resource in BulkArchiveMailer.Core.csproj.");
}
using (var reader = new StreamReader(stream, Encoding.UTF8))
{
return reader.ReadToEnd();
}
}
}
public static AppSettingsData LoadDefaults() => Deserialize(ReadEmbeddedDefaultJson());
/// <summary>
/// Loads settings from disk, seeding the file from the embedded
/// defaults on first run. A malformed hand-edited config.json is
/// 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()
{
if (!File.Exists(ConfigFilePath))
{
Directory.CreateDirectory(ConfigDirectory);
File.WriteAllText(ConfigFilePath, ReadEmbeddedDefaultJson(), new UTF8Encoding(false));
}
var json = File.ReadAllText(ConfigFilePath, Encoding.UTF8);
var data = DeserializeWithLegacyFallback(json, out var wasLegacyMigration);
if (wasLegacyMigration)
{
Save(data);
}
return data;
}
public static void Save(AppSettingsData data)
{
if (data == null)
{
throw new ArgumentNullException(nameof(data));
}
Directory.CreateDirectory(ConfigDirectory);
File.WriteAllText(ConfigFilePath, Serialize(data), new UTF8Encoding(false));
}
/// <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&lt;MailTemplateData&gt;
/// (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(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&lt;MailTemplateData&gt; 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 ?? "",
// The legacy shape never had a per-template login
// filter - migrated entries keep the old
// unrestricted "suffix alone decides the match"
// behavior.
LoginPattern = "",
},
new MailTemplateData
{
DisplayName = DefaultDisplayName("config_update"),
FileSuffix = "config_update",
Subject = legacy?.Templates?.ConfigUpdate?.Subject ?? "",
BodyHtml = legacy?.Templates?.ConfigUpdate?.BodyHtml ?? "",
LoginPattern = "",
},
},
};
return migrated;
}
private static string Serialize(AppSettingsData data)
{
using (var ms = new MemoryStream())
{
var serializer = new DataContractJsonSerializer(typeof(AppSettingsData));
serializer.WriteObject(ms, data);
var minified = Encoding.UTF8.GetString(ms.ToArray());
return PrettyPrintJson(minified);
}
}
/// <summary>
/// DataContractJsonSerializer has no built-in indentation option
/// and writes single-line JSON. This tiny formatter re-indents it
/// so config.json stays comfortable to hand-edit (it is, after
/// all, the officially supported way to change the mail
/// templates). It only tracks string-literal boundaries, which is
/// sufficient for the flat/shallow shape this file always has.
/// </summary>
private static string PrettyPrintJson(string json)
{
var sb = new StringBuilder(json.Length * 2);
var indent = 0;
var inString = false;
var escapeNext = false;
for (var i = 0; i < json.Length; i++)
{
var c = json[i];
if (inString)
{
sb.Append(c);
if (escapeNext)
{
escapeNext = false;
}
else if (c == '\\')
{
escapeNext = true;
}
else if (c == '"')
{
inString = false;
}
continue;
}
switch (c)
{
case '"':
inString = true;
sb.Append(c);
break;
case '{':
case '[':
sb.Append(c);
if (i + 1 < json.Length && (json[i + 1] == '}' || json[i + 1] == ']'))
{
sb.Append(json[i + 1]);
i++;
}
else
{
indent++;
sb.Append('\n').Append(' ', indent * 2);
}
break;
case '}':
case ']':
indent--;
sb.Append('\n').Append(' ', indent * 2).Append(c);
break;
case ',':
sb.Append(c).Append('\n').Append(' ', indent * 2);
break;
case ':':
sb.Append(c).Append(' ');
break;
default:
sb.Append(c);
break;
}
}
return sb.ToString();
}
}
}