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

@@ -28,6 +28,7 @@ namespace BulkArchiveMailer.AddIn
{
private readonly Outlook.Application application;
private readonly string signatureHtml;
private readonly string fromAccountSmtpAddress;
/// <param name="application">Live Outlook.Application (from ThisAddIn.Application) used to create mail items.</param>
/// <param name="signatureHtml">
@@ -37,10 +38,18 @@ namespace BulkArchiveMailer.AddIn
/// appended to HTMLBody by hand). May be null/empty if no
/// signature is configured; the mail is then sent without one.
/// </param>
public MailSender(Outlook.Application application, string signatureHtml)
/// <param name="fromAccountSmtpAddress">
/// SmtpAddress of the Outlook account (from Session.Accounts) to
/// send from, e.g. a shared mailbox distinct from the operator's
/// own primary account (AppSettingsData.FromAccountSmtpAddress).
/// Null/empty means "use whatever account Outlook picks by
/// default" - the previous, unconfigurable behavior.
/// </param>
public MailSender(Outlook.Application application, string signatureHtml, string fromAccountSmtpAddress = null)
{
this.application = application ?? throw new ArgumentNullException(nameof(application));
this.signatureHtml = signatureHtml ?? string.Empty;
this.fromAccountSmtpAddress = fromAccountSmtpAddress ?? string.Empty;
}
/// <param name="job">Row to send. Caller is responsible for only passing selectable (Ready/AlreadySent), checked rows - this method does not re-validate Status.</param>
@@ -59,6 +68,26 @@ namespace BulkArchiveMailer.AddIn
mailItem.To = job.Email;
mailItem.Subject = job.Subject;
if (!string.IsNullOrEmpty(fromAccountSmtpAddress))
{
var account = TryFindAccountBySmtpAddress(fromAccountSmtpAddress);
if (account != null)
{
try
{
mailItem.SendUsingAccount = account;
}
finally
{
Marshal.ReleaseComObject(account);
}
}
// Account no longer present in this Outlook session
// (removed/renamed since it was configured) - fall
// through and let Outlook use its own default account,
// same as when the setting is empty.
}
var body = job.BodyHtml ?? string.Empty;
if (!string.IsNullOrEmpty(signatureHtml))
{
@@ -95,5 +124,63 @@ namespace BulkArchiveMailer.AddIn
}
}
}
/// <summary>
/// Looks up a live Outlook.Account in the current session by
/// SmtpAddress (case-insensitive). Returns null - never throws -
/// if the account isn't found or Session/Accounts can't be read
/// for any reason, so a stale/renamed configured account degrades
/// to "use Outlook's default" instead of failing the send.
///
/// Ownership: the returned Account (if any) is a fresh RCW the
/// caller is responsible for releasing via
/// Marshal.ReleaseComObject once done with it, matching the
/// release-what-you-touch convention used throughout this class
/// and in SignatureReader.
/// </summary>
private Outlook.Account TryFindAccountBySmtpAddress(string smtpAddress)
{
Outlook.Accounts accounts = null;
try
{
accounts = application.Session.Accounts;
foreach (Outlook.Account candidate in accounts)
{
bool isMatch;
try
{
isMatch = string.Equals(candidate.SmtpAddress, smtpAddress, StringComparison.OrdinalIgnoreCase);
}
catch
{
isMatch = false;
}
if (isMatch)
{
return candidate; // ownership passed to the caller
}
Marshal.ReleaseComObject(candidate);
}
return null;
}
catch
{
return null;
}
finally
{
if (accounts != null)
{
Marshal.ReleaseComObject(accounts);
}
// application.Session (the NameSpace) is a long-lived
// object owned by Outlook itself - like `application`, it
// is never released here.
}
}
}
}