Fix first real VSTO build, add sender account selection, polish deploy

First live build against real Visual Studio/VSTO tooling surfaced two
issues in the hand-written AddIn scaffold: ThisAddIn derived from the
wrong base type (OutlookAddIn is an interface, not the concrete
OutlookAddInBase class), and VSTO Application projects cannot build
without a signed ClickOnce manifest, so SignManifests is now on with a
throwaway self-signed test certificate. Both Core/Core.Tests/AddIn now
build clean and Core.Tests passes.

Also: mail always sent from Outlook's default account with no way to
pick a different one (e.g. a shared mailbox) - added a "From account"
setting (Session.Accounts dropdown in SettingsForm, SendUsingAccount in
MailSender). And added deploy/ with both a local ClickOnce publish
script (real install/uninstall via Windows Apps, no manual registry
editing) and a manual registry sideload fallback for scripted rollouts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
redlineoandClaude Sonnet 5 committed 2026-07-24 09:51:52 +03:00
1 parent 7f0e0fdb23
commit 264ed74095
20 files changed
+756 -67

No files matched your search

@@ -0,0 +1,73 @@
using System.Reflection;
using BulkArchiveMailer.Core;
using Xunit;
namespace BulkArchiveMailer.Core.Tests
{
/// <summary>
/// Covers the one AppSettingsData behavior that's easy to silently
/// break when adding a field: loading a config.json written before
/// FromAccountSmtpAddress existed must not throw, and a non-empty
/// value must round-trip once the operator picks a "from" account in
/// SettingsForm.
///
/// Note the old-JSON case asserts null, not "": DataContractJsonSerializer
/// builds the target via FormatterServices.GetUninitializedObject,
/// which skips this property's "" initializer entirely for any member
/// absent from the source JSON, leaving the CLR default (null) - see
/// the long comment on AppSettingsData.FromAccountSmtpAddress. Every
/// call site (MailSender, SettingsForm) already treats null the same
/// as "" for this reason.
///
/// Goes through AppSettings' private Serialize/Deserialize via
/// reflection rather than Load()/Save(), so this test never touches
/// the real %APPDATA%\BulkArchiveMailer\config.json on the machine
/// running it.
/// </summary>
public class AppSettingsTests
{
[Fact]
public void Deserialize_OldJsonWithoutFromAccountField_DoesNotThrowAndTreatsAsUnset()
{
const string oldJson = @"{
""archiveFolder"": ""C:\\archives"",
""emailDomain"": ""redlab.lo"",
""testModeDefault"": true,
""signatureHtmlPath"": """"
}";
var settings = InvokeDeserialize(oldJson);
Assert.True(string.IsNullOrEmpty(settings.FromAccountSmtpAddress));
Assert.Equal("redlab.lo", settings.EmailDomain);
}
[Fact]
public void SerializeThenDeserialize_RoundTripsFromAccountSmtpAddress()
{
var data = new AppSettingsData
{
ArchiveFolder = "C:\\archives",
EmailDomain = "redlab.lo",
FromAccountSmtpAddress = "svc-mailer@redlab.lo",
};
var json = InvokeSerialize(data);
var roundTripped = InvokeDeserialize(json);
Assert.Equal("svc-mailer@redlab.lo", roundTripped.FromAccountSmtpAddress);
}
private static AppSettingsData InvokeDeserialize(string json)
{
var method = typeof(AppSettings).GetMethod("Deserialize", BindingFlags.NonPublic | BindingFlags.Static);
return (AppSettingsData)method.Invoke(null, new object[] { json });
}
private static string InvokeSerialize(AppSettingsData data)
{
var method = typeof(AppSettings).GetMethod("Serialize", BindingFlags.NonPublic | BindingFlags.Static);
return (string)method.Invoke(null, new object[] { data });
}
}
}
@@ -12,7 +12,7 @@
-->
<PropertyGroup>
<TargetFramework>net48</TargetFramework>
<TargetFramework>net481</TargetFramework>
<IsPackable>false</IsPackable>
<LangVersion>7.3</LangVersion>
</PropertyGroup>