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();
}
}
}