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 { /// One entry of AppSettingsData.Templates - the JSON shape of a MailTemplateDefinition. [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; } = ""; } /// Shape of %APPDATA%\BulkArchiveMailer\config.json (and of the embedded config.default.json that seeds it). [DataContract] public sealed class AppSettingsData { [DataMember(Name = "archiveFolder")] public string ArchiveFolder { get; set; } = ""; [DataMember(Name = "emailDomain")] public string EmailDomain { get; set; } = "redlab.lo"; /// 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. [DataMember(Name = "testModeDefault")] public bool TestModeDefault { get; set; } = true; /// 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. [DataMember(Name = "signatureHtmlPath")] public string SignatureHtmlPath { get; set; } = ""; /// /// 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. /// [DataMember(Name = "fromAccountSmtpAddress")] public string FromAccountSmtpAddress { get; set; } = ""; /// /// 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. /// [DataMember(Name = "templates")] public List Templates { get; set; } = new List(); public List ToTemplateList() { var list = new List(); 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 ?? "", }); } return list; } /// 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). public void SetTemplates(List templates) { var list = new List(); if (templates != null) { foreach (var t in templates) { list.Add(new MailTemplateData { DisplayName = t?.DisplayName ?? "", FileSuffix = t?.FileSuffix ?? "", Subject = t?.Subject ?? "", BodyHtml = t?.BodyHtml ?? "", }); } } Templates = list; } } /// /// 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. /// public static class AppSettings { private const string EmbeddedDefaultResourceName = "BulkArchiveMailer.Core.config.default.json"; /// /// 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. /// [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()); /// /// 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. /// 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)); } /// /// 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. /// 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(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(json); } private static T DeserializeAs(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); } } /// /// 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. /// 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; } } /// /// 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. /// 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 { 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()) { var serializer = new DataContractJsonSerializer(typeof(AppSettingsData)); serializer.WriteObject(ms, data); var minified = Encoding.UTF8.GetString(ms.ToArray()); return PrettyPrintJson(minified); } } /// /// 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. /// 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(); } } }