using System; using System.IO; using System.Reflection; using System.Runtime.Serialization; using System.Runtime.Serialization.Json; using System.Text; namespace BulkArchiveMailer.Core { [DataContract] public sealed class TemplateData { [DataMember(Name = "subject")] public string Subject { get; set; } = ""; [DataMember(Name = "bodyHtml")] 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(); } /// 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; } = ""; [DataMember(Name = "templates")] public TemplateSetData Templates { get; set; } = new TemplateSetData(); public MailTemplateSet ToTemplateSet() { return new MailTemplateSet { SshAccess = new MailTemplate { Subject = Templates?.SshAccess?.Subject ?? "", BodyHtml = Templates?.SshAccess?.BodyHtml ?? "", }, ConfigUpdate = new MailTemplate { Subject = Templates?.ConfigUpdate?.Subject ?? "", BodyHtml = Templates?.ConfigUpdate?.BodyHtml ?? "", }, }; } public void SetTemplate(ArchiveKind kind, MailTemplate template) { Templates = Templates ?? new TemplateSetData(); var data = new TemplateData { Subject = template?.Subject ?? "", BodyHtml = template?.BodyHtml ?? "" }; if (kind == ArchiveKind.SshAccess) { Templates.SshAccess = data; } else { Templates.ConfigUpdate = data; } } } /// /// 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"; 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. /// 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); return Deserialize(json); } 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)); } private static AppSettingsData Deserialize(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); } } 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(); } } }