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

@@ -12,7 +12,7 @@
<OutputType>Library</OutputType>
<RootNamespace>BulkArchiveMailer.AddIn</RootNamespace>
<AssemblyName>BulkArchiveMailer.AddIn</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.8.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<ProjectTypeGuids>{BAA0C2D2-18E2-41B9-852F-F413020CAA33};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}</ProjectTypeGuids>
<LangVersion>7.3</LangVersion>
@@ -25,19 +25,47 @@
<VSTOFrameworkVer40>true</VSTOFrameworkVer40>
<!--
Signing is deliberately OFF: this is an internal tool, sideloaded by
hand (see docs/BUILD.md) rather than published/ClickOnce/GPO
deployed, so there is no certificate to manage here. On first load
Outlook will show an "unable to verify publisher" style prompt for
the unsigned manifest - that's expected; docs/BUILD.md covers how to
either accept it or add a Trusted Location. Turning signing on for a
wider rollout is documented there as a follow-up, not implemented
here - a judgment call, since fabricating a certificate in an
environment with no Visual Studio/dotnet SDK to actually sign
anything would just be decoration.
Same MSBuild properties Visual Studio's Publish → Options dialog
writes here - without them ClickOnce falls back to the bare
assembly name ("BulkArchiveMailer.AddIn", from TargetName - see
Microsoft.VisualStudio.Tools.Office.targets'
"<ProductName Condition=... >$(TargetName)</ProductName>") both in
the install dialog and in Settings → Apps, which reads as
unfinished/unbranded to an operator installing this for the first
time. Property name is ProductName, NOT Product - Product is not a
recognized ClickOnce/VSTO manifest property and is silently ignored,
confirmed by grepping the generated .vsto after a real -t:Publish
(see deploy/publish-clickonce.ps1).
-->
<ProductName>BulkArchiveMailer — массовая рассылка архивов</ProductName>
<PublisherName>ssh-access-tui</PublisherName>
<!--
Corrected after the first real build against actual VSTO/MSBuild
tooling: unlike a plain ClickOnce EXE, a VSTO Office add-in's
"VisualStudioForApplicationsBuild" build step (Microsoft.VisualStudio.
Tools.Office.targets) unconditionally generates and signs a ClickOnce
application/deployment manifest even for local Debug builds -
SignManifests=false makes VerifyClickOnceSigningSettings fail the
build outright ("Cannot build because the ClickOnce manifest signing
option is not selected"). There is no way to opt out of this for an
Application-type VSTO project, so a throwaway self-signed "test"
certificate (BulkArchiveMailer.AddIn_TemporaryKey.pfx, no password,
not from a trusted CA) is checked into this folder purely to satisfy
that requirement - the same thing VS itself generates automatically
the first time you tick "Sign the ClickOnce manifests" in the
project's Signing property page. It does NOT make the add-in
"trusted": Outlook still shows an "unable to verify publisher"-style
prompt on first load because the certificate isn't from a trusted
root, same as before this fix - docs/BUILD.md still covers how to
accept that prompt or add a Trusted Location. Assembly strong-name
signing (SignAssembly) is a separate, unrelated concept and stays
off - nothing requires it.
-->
<SignAssembly>false</SignAssembly>
<SignManifests>false</SignManifests>
<SignManifests>true</SignManifests>
<ManifestKeyFile>BulkArchiveMailer.AddIn_TemporaryKey.pfx</ManifestKeyFile>
<ManifestCertificateThumbprint>9A9AF6F0E6EF09093910BD0FA2CCFFEC81F00E25</ManifestCertificateThumbprint>
</PropertyGroup>
<PropertyGroup Condition=" '$(Configuration)|$(Platform)' == 'Debug|AnyCPU' ">
<DebugSymbols>true</DebugSymbols>
@@ -95,6 +123,19 @@
<Reference Include="Microsoft.Office.Tools.Outlook, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
</Reference>
<!--
Microsoft.Office.Tools.Outlook.dll only exposes the OutlookAddIn
*interface* (FormRegion-related members). The real concrete base
class ThisAddIn derives from, Microsoft.Office.Tools.Outlook.
OutlookAddInBase, lives in this "Utilities" assembly instead - same
as the built-in "Outlook VSTO Add-in" project template, which also
references it with Private=True (runtime-only helper, not part of
the public design-time surface).
-->
<Reference Include="Microsoft.Office.Tools.Outlook.v4.0.Utilities, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
<Private>True</Private>
</Reference>
<Reference Include="Microsoft.Office.Tools.v4.0.Framework, Version=10.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a, processorArchitecture=MSIL">
<SpecificVersion>False</SpecificVersion>
</Reference>
@@ -111,6 +152,7 @@
<ItemGroup>
<Compile Include="Globals.cs" />
<Compile Include="MailSender.cs" />
<Compile Include="OutlookAccounts.cs" />
<Compile Include="RibbonController.cs" />
<Compile Include="SignatureReader.cs" />
<Compile Include="ThisAddIn.cs" />
@@ -136,6 +178,15 @@
</ItemGroup>
<ItemGroup>
<None Include="app.config" />
<!--
Test certificate for ClickOnce manifest signing only (see SignManifests
comment above) - no password. VSTO's ManageCertificateStore build task
picks up *.pfx files listed here and imports them into the current
user's certificate store (CurrentUser\My) if a certificate matching
ManifestCertificateThumbprint isn't already present, so a fresh clone
builds without any manual "import the cert" step.
-->
<None Include="BulkArchiveMailer.AddIn_TemporaryKey.pfx" />
</ItemGroup>
<Import Project="$(MSBuildBinPath)\Microsoft.CSharp.targets" />
<Import Project="$(VSToolsPath)\OfficeTools\Microsoft.VisualStudio.Tools.Office.targets" Condition="'$(VSToolsPath)' != ''" />
@@ -5,10 +5,50 @@ namespace BulkArchiveMailer.AddIn
/// shape Visual Studio normally generates for VSTO projects (hand
/// written here for the same reason as ThisAddIn.Designer.cs - see
/// its header comment).
///
/// <c>Microsoft.Office.Tools.Outlook.OutlookAddIn</c> has no static
/// "GetInstance" helper (it is just the FormRegion-related interface
/// implemented by the real base class, OutlookAddInBase) - the
/// running instance is instead captured directly by
/// ThisAddIn.Initialize() into <see cref="ThisAddIn"/> below, exactly
/// like the real VSTO-generated Globals class does via its
/// <c>Globals.ThisAddIn = this;</c> assignment.
/// </summary>
internal static class Globals
{
internal static ThisAddIn ThisAddIn =>
(ThisAddIn)Microsoft.Office.Tools.Outlook.OutlookAddIn.GetInstance(typeof(ThisAddIn));
private static ThisAddIn _thisAddIn;
private static Microsoft.Office.Tools.Outlook.Factory _factory;
internal static ThisAddIn ThisAddIn
{
get { return _thisAddIn; }
set
{
if (_thisAddIn == null)
{
_thisAddIn = value;
}
else
{
throw new System.NotSupportedException();
}
}
}
internal static Microsoft.Office.Tools.Outlook.Factory Factory
{
get { return _factory; }
set
{
if (_factory == null)
{
_factory = value;
}
else
{
throw new System.NotSupportedException();
}
}
}
}
}
@@ -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.
}
}
}
}
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace BulkArchiveMailer.AddIn
{
/// <summary>Read-only snapshot of one Outlook.Account, taken once while the COM object was still alive - safe to hold onto and display after the Account RCW itself has been released.</summary>
public sealed class OutlookAccountInfo
{
public string DisplayName { get; set; } = "";
public string SmtpAddress { get; set; } = "";
}
/// <summary>
/// Enumerates Outlook.Application.Session.Accounts for UI purposes
/// (the "Отправлять от:" picker in SettingsForm) without leaking any
/// COM references - mirrors the release-what-you-touch convention used
/// in MailSender/SignatureReader. Never throws: on any failure it
/// returns an empty list so a picker can simply fall back to "default
/// account only" instead of crashing the Settings window.
/// </summary>
public static class OutlookAccounts
{
public static List<OutlookAccountInfo> ListAccounts(Outlook.Application application)
{
var result = new List<OutlookAccountInfo>();
if (application == null)
{
return result;
}
Outlook.Accounts accounts = null;
try
{
accounts = application.Session.Accounts;
foreach (Outlook.Account account in accounts)
{
try
{
result.Add(new OutlookAccountInfo
{
DisplayName = account.DisplayName ?? "",
SmtpAddress = account.SmtpAddress ?? "",
});
}
catch
{
// Skip an individual account we couldn't read
// rather than losing the rest of the list.
}
finally
{
Marshal.ReleaseComObject(account);
}
}
}
catch
{
return new List<OutlookAccountInfo>();
}
finally
{
if (accounts != null)
{
Marshal.ReleaseComObject(accounts);
}
// application.Session (NameSpace) is a long-lived object
// owned by Outlook itself - never released here, same as
// in MailSender.
}
return result;
}
}
}
@@ -1,24 +1,68 @@
namespace BulkArchiveMailer.AddIn
{
/// <summary>
/// Designer-generated half of ThisAddIn. Normally Visual Studio
/// regenerates this file's contents automatically the first time the
/// project is opened/built (it derives from
/// Microsoft.Office.Tools.Outlook.OutlookAddIn and wires Startup/
/// Shutdown) - it is hand-written here only because this environment
/// has no VSTO tooling to run the code generator. Do not hand-edit
/// once opened in VS if the IDE regenerates it differently.
/// Designer-generated half of ThisAddIn. Normally Visual Studio's VSTO
/// project system regenerates this file automatically from
/// ThisAddIn.Designer.xml the first time the project is opened/edited
/// in the IDE (host-item "blueprint" mechanism) - it is hand-written
/// here only because this environment had no VSTO tooling to run that
/// generator, and later corrected to match the real base class after
/// the first build against the actual VSTO assemblies (see git log).
///
/// The real base class is NOT the <c>Microsoft.Office.Tools.Outlook.OutlookAddIn</c>
/// interface (that only declares the FormRegion-related members) but
/// <c>Microsoft.Office.Tools.Outlook.OutlookAddInBase</c> - a concrete
/// abstract class living in Microsoft.Office.Tools.Outlook.v4.0.Utilities.dll
/// (referenced with SpecificVersion=False/Private=True in the .csproj,
/// same as the built-in "Outlook VSTO Add-in" project template), which
/// itself derives from Microsoft.Office.Tools.AddInBase and already
/// implements OutlookAddIn/AddIn/IBindableComponent/IComponent/
/// IDisposable, plus exposes the protected virtual
/// CreateRibbonExtensibilityObject() overridden in ThisAddIn.cs.
///
/// Do not hand-edit once opened in VS if the IDE regenerates it
/// differently.
/// </summary>
partial class ThisAddIn : Microsoft.Office.Tools.Outlook.OutlookAddIn
[Microsoft.VisualStudio.Tools.Applications.Runtime.StartupObjectAttribute(0)]
[global::System.Security.Permissions.PermissionSetAttribute(global::System.Security.Permissions.SecurityAction.Demand, Name = "FullTrust")]
public sealed partial class ThisAddIn : Microsoft.Office.Tools.Outlook.OutlookAddInBase
{
/// <summary>Populated from the host item in <see cref="Initialize"/>; exposed to the rest of the add-in via the OutlookApplication alias in ThisAddIn.cs.</summary>
internal Microsoft.Office.Interop.Outlook.Application Application;
/// <summary>
/// Required constructor for VSTO add-in support - do not modify
/// the contents of this method with the code editor. The VSTO
/// runtime instantiates ThisAddIn through this constructor via
/// reflection (see the StartupObjectAttribute above).
/// </summary>
public ThisAddIn(Microsoft.Office.Tools.Outlook.Factory factory, System.IServiceProvider serviceProvider)
: base(factory, serviceProvider, "AddIn", "ThisAddIn")
{
Globals.Factory = factory;
}
/// <summary>
/// Required method for Designer support - do not modify the
/// contents of this method with the code editor.
/// </summary>
private void InternalStartup()
protected override void Initialize()
{
this.Startup += new System.EventHandler(ThisAddIn_Startup);
this.Shutdown += new System.EventHandler(ThisAddIn_Shutdown);
base.Initialize();
this.Application = this.GetHostItem<Microsoft.Office.Interop.Outlook.Application>(
typeof(Microsoft.Office.Interop.Outlook.Application), "Application");
Globals.ThisAddIn = this;
global::System.Windows.Forms.Application.EnableVisualStyles();
}
/// <summary>
/// Required method for Designer support - do not modify the
/// contents of this method with the code editor.
/// </summary>
protected override void FinishInitialization()
{
this.InternalStartup();
this.OnStartup();
}
}
}
@@ -28,5 +28,20 @@ namespace BulkArchiveMailer.AddIn
{
return new RibbonController();
}
#region VSTO generated code
/// <summary>
/// Required method for Designer support - do not modify the
/// contents of this method with the code editor. Called from
/// ThisAddIn.Designer.cs's FinishInitialization() override.
/// </summary>
private void InternalStartup()
{
this.Startup += new EventHandler(ThisAddIn_Startup);
this.Shutdown += new EventHandler(ThisAddIn_Shutdown);
}
#endregion
}
}
@@ -215,7 +215,7 @@ namespace BulkArchiveMailer.AddIn.UI
progressSend.Maximum = toSend.Count;
progressSend.Value = 0;
var mailSender = new MailSender(outlookApplication, signatureHtml);
var mailSender = new MailSender(outlookApplication, signatureHtml, settings.FromAccountSmtpAddress);
var sentCount = 0;
var failedCount = 0;
@@ -33,6 +33,9 @@ namespace BulkArchiveMailer.AddIn.UI
private System.Windows.Forms.CheckBox chkTestModeDefault;
private System.Windows.Forms.Label lblFromAccount;
private System.Windows.Forms.ComboBox cboFromAccount;
private System.Windows.Forms.Label lblSshSubject;
private System.Windows.Forms.TextBox txtSshSubject;
private System.Windows.Forms.Label lblSshBody;
@@ -70,6 +73,9 @@ namespace BulkArchiveMailer.AddIn.UI
this.chkTestModeDefault = new System.Windows.Forms.CheckBox();
this.lblFromAccount = new System.Windows.Forms.Label();
this.cboFromAccount = new System.Windows.Forms.ComboBox();
this.lblSshSubject = new System.Windows.Forms.Label();
this.txtSshSubject = new System.Windows.Forms.TextBox();
this.lblSshBody = new System.Windows.Forms.Label();
@@ -121,6 +127,8 @@ namespace BulkArchiveMailer.AddIn.UI
this.tabGeneral.Controls.Add(this.btnBrowseSignature);
this.tabGeneral.Controls.Add(this.btnAutoDetectSignature);
this.tabGeneral.Controls.Add(this.chkTestModeDefault);
this.tabGeneral.Controls.Add(this.lblFromAccount);
this.tabGeneral.Controls.Add(this.cboFromAccount);
// lblArchiveFolder
this.lblArchiveFolder.Location = new System.Drawing.Point(15, 20);
@@ -176,6 +184,16 @@ namespace BulkArchiveMailer.AddIn.UI
this.chkTestModeDefault.Text = "По умолчанию открывать предпросмотр в тестовом режиме (сохранять в Черновики)";
this.chkTestModeDefault.UseVisualStyleBackColor = true;
// lblFromAccount
this.lblFromAccount.Location = new System.Drawing.Point(15, 284);
this.lblFromAccount.Size = new System.Drawing.Size(400, 20);
this.lblFromAccount.Text = "Отправлять от (аккаунт Outlook текущей сессии):";
// cboFromAccount
this.cboFromAccount.Location = new System.Drawing.Point(15, 307);
this.cboFromAccount.Size = new System.Drawing.Size(500, 23);
this.cboFromAccount.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
//
// tabSshAccess
//
@@ -42,6 +42,16 @@ namespace BulkArchiveMailer.AddIn.UI
PopulateFrom(settings);
}
/// <summary>ComboBox item: displays a friendly label but carries the SmtpAddress that actually gets saved to config.json. The first item (SmtpAddress == "") represents "let Outlook pick its default account".</summary>
private sealed class FromAccountItem
{
public string Label { get; set; } = "";
public string SmtpAddress { get; set; } = "";
public override string ToString() => Label;
}
private const string DefaultAccountLabel = "(по умолчанию Outlook)";
private void PopulateFrom(AppSettingsData settings)
{
txtArchiveFolder.Text = settings.ArchiveFolder ?? "";
@@ -49,6 +59,8 @@ namespace BulkArchiveMailer.AddIn.UI
txtSignaturePath.Text = settings.SignatureHtmlPath ?? "";
chkTestModeDefault.Checked = settings.TestModeDefault;
PopulateFromAccountCombo(settings.FromAccountSmtpAddress ?? "");
var templates = settings.ToTemplateSet();
txtSshSubject.Text = templates.SshAccess.Subject;
txtSshBody.Text = templates.SshAccess.BodyHtml;
@@ -56,6 +68,49 @@ namespace BulkArchiveMailer.AddIn.UI
txtCfgBody.Text = templates.ConfigUpdate.BodyHtml;
}
/// <summary>
/// Fills cboFromAccount with "(по умолчанию Outlook)" plus one
/// entry per real Outlook.Account in the current session, then
/// selects the item matching savedSmtpAddress. If that address no
/// longer belongs to any account in this session (removed/renamed
/// since it was configured, or Outlook.Application is unavailable
/// e.g. under a design-time host), it just falls back to the
/// default item instead of throwing - never blocks opening the
/// Settings window over this.
/// </summary>
private void PopulateFromAccountCombo(string savedSmtpAddress)
{
cboFromAccount.Items.Clear();
cboFromAccount.Items.Add(new FromAccountItem { Label = DefaultAccountLabel, SmtpAddress = "" });
if (outlookApplication != null)
{
foreach (var account in OutlookAccounts.ListAccounts(outlookApplication))
{
var label = string.IsNullOrEmpty(account.SmtpAddress)
? account.DisplayName
: account.DisplayName + " <" + account.SmtpAddress + ">";
cboFromAccount.Items.Add(new FromAccountItem { Label = label, SmtpAddress = account.SmtpAddress });
}
}
var selectedIndex = 0;
if (!string.IsNullOrEmpty(savedSmtpAddress))
{
for (var i = 0; i < cboFromAccount.Items.Count; i++)
{
var item = (FromAccountItem)cboFromAccount.Items[i];
if (string.Equals(item.SmtpAddress, savedSmtpAddress, StringComparison.OrdinalIgnoreCase))
{
selectedIndex = i;
break;
}
}
}
cboFromAccount.SelectedIndex = selectedIndex;
}
private void btnBrowseFolder_Click(object sender, EventArgs e)
{
using (var dialog = new FolderBrowserDialog())
@@ -157,12 +212,15 @@ namespace BulkArchiveMailer.AddIn.UI
}
}
var selectedAccount = cboFromAccount.SelectedItem as FromAccountItem;
var data = new AppSettingsData
{
ArchiveFolder = txtArchiveFolder.Text.Trim(),
EmailDomain = txtDomain.Text.Trim(),
SignatureHtmlPath = txtSignaturePath.Text.Trim(),
TestModeDefault = chkTestModeDefault.Checked,
FromAccountSmtpAddress = selectedAccount?.SmtpAddress ?? "",
};
data.SetTemplate(ArchiveKind.SshAccess, new MailTemplate { Subject = txtSshSubject.Text, BodyHtml = txtSshBody.Text });
data.SetTemplate(ArchiveKind.ConfigUpdate, new MailTemplate { Subject = txtCfgSubject.Text, BodyHtml = txtCfgBody.Text });
@@ -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>
@@ -45,6 +45,31 @@ namespace BulkArchiveMailer.Core
[DataMember(Name = "signatureHtmlPath")]
public string SignatureHtmlPath { get; set; } = "";
/// <summary>
/// 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.
/// </summary>
[DataMember(Name = "fromAccountSmtpAddress")]
public string FromAccountSmtpAddress { get; set; } = "";
[DataMember(Name = "templates")]
public TemplateSetData Templates { get; set; } = new TemplateSetData();
@@ -8,7 +8,7 @@
<OutputType>Library</OutputType>
<RootNamespace>BulkArchiveMailer.Core</RootNamespace>
<AssemblyName>BulkArchiveMailer.Core</AssemblyName>
<TargetFrameworkVersion>v4.8</TargetFrameworkVersion>
<TargetFrameworkVersion>v4.8.1</TargetFrameworkVersion>
<FileAlignment>512</FileAlignment>
<Deterministic>true</Deterministic>
<LangVersion>7.3</LangVersion>
@@ -3,6 +3,7 @@
"emailDomain": "redlab.lo",
"testModeDefault": true,
"signatureHtmlPath": "",
"fromAccountSmtpAddress": "",
"templates": {
"ssh_access": {
"subject": "Доступ к SSH jump-хосту — {Login}",
+4
View File
@@ -89,6 +89,10 @@ Outlook в принципе не запустить.
подпись определяется автоматически по умолчанию Outlook для новых писем)
либо указать конкретный файл вручную — например, если у оператора настроена
не та подпись, которую нужно вставлять в такие письма.
- **Отправлять от** — выпадающий список аккаунтов текущей сессии Outlook
(`Session.Accounts`), полезно, если у оператора несколько ящиков (например,
отдельный служебный/shared-ящик именно для этой рассылки). Пункт
"(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта.
- **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и
`config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`.
- **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются
@@ -0,0 +1,12 @@
Windows Registry Editor Version 5.00
; Sideload регистрация BulkArchiveMailer для текущего пользователя.
; Перед применением скопируйте всю папку bin\Release\ в C:\Tools\BulkArchiveMailer\
; (или другой стабильный путь - тогда поправьте "Manifest" ниже под свой путь).
; Подробности и типичные проблемы - outlook-bulk-sender/docs/BUILD.md.
[HKEY_CURRENT_USER\Software\Microsoft\Office\Outlook\Addins\BulkArchiveMailer.AddIn]
"Description"="Массовая рассылка архивов ssh-access-tui"
"FriendlyName"="BulkArchiveMailer"
"Manifest"="file:///C:\\Tools\\BulkArchiveMailer\\BulkArchiveMailer.AddIn.vsto|vstolocal"
"LoadBehavior"=dword:00000003
@@ -0,0 +1,103 @@
<#
.SYNOPSIS
Rebuilds the ClickOnce installer for BulkArchiveMailer.AddIn (Способ А
in ../docs/BUILD.md) without hand-typing the long MSBuild -t:Publish
command each time.
.DESCRIPTION
Wraps:
MSBuild.exe BulkArchiveMailer.AddIn\BulkArchiveMailer.AddIn.csproj -t:Publish -p:Configuration=Release
-p:PublishUrl=<repo>\BulkArchiveMailer.AddIn\bin\Release\app.publish\
-p:InstallUrl=<repo>\BulkArchiveMailer.AddIn\bin\Release\app.publish\
-p:PublishDir=bin\Release\app.publish\ -p:UpdateEnabled=false
MSBuild.exe is located via vswhere.exe first (works across VS
installs/versions/editions); if vswhere itself isn't found, falls back
to the fixed path this repo has been built with so far
(D:\VS2022\MSBuild\Current\Bin\amd64\MSBuild.exe). Pass -MSBuildPath to
override either lookup explicitly.
.PARAMETER MSBuildPath
Explicit path to MSBuild.exe. Skips both the vswhere lookup and the
hardcoded fallback when supplied.
.EXAMPLE
.\deploy\publish-clickonce.ps1
.EXAMPLE
.\deploy\publish-clickonce.ps1 -MSBuildPath 'C:\Program Files\Microsoft Visual Studio\2022\Community\MSBuild\Current\Bin\amd64\MSBuild.exe'
#>
[CmdletBinding()]
param(
[string]$MSBuildPath
)
$ErrorActionPreference = 'Stop'
# Repo layout: this script lives in outlook-bulk-sender\deploy\, the
# project lives one level up in outlook-bulk-sender\BulkArchiveMailer.AddIn\.
$repoRoot = Split-Path -Parent $PSScriptRoot
$projectPath = Join-Path $repoRoot 'BulkArchiveMailer.AddIn\BulkArchiveMailer.AddIn.csproj'
$publishDir = Join-Path $repoRoot 'BulkArchiveMailer.AddIn\bin\Release\app.publish\'
if (-not (Test-Path $projectPath)) {
throw "Project file not found: $projectPath"
}
function Resolve-MSBuildPath {
param([string]$explicitPath)
if ($explicitPath) {
if (-not (Test-Path $explicitPath)) {
throw "-MSBuildPath was given but does not exist: $explicitPath"
}
return $explicitPath
}
$vswhere = 'C:\Program Files (x86)\Microsoft Visual Studio\Installer\vswhere.exe'
if (Test-Path $vswhere) {
$found = & $vswhere -latest -requires Microsoft.Component.MSBuild -find 'MSBuild\**\Bin\MSBuild.exe' 2>$null |
Select-Object -First 1
if ($found -and (Test-Path $found)) {
Write-Host "Using MSBuild found via vswhere: $found"
return $found
}
Write-Warning 'vswhere.exe ran but did not report a usable MSBuild.exe - falling back to the fixed path.'
} else {
Write-Warning "vswhere.exe not found at '$vswhere' - falling back to the fixed path."
}
$fallback = 'D:\VS2022\MSBuild\Current\Bin\amd64\MSBuild.exe'
if (-not (Test-Path $fallback)) {
throw "Could not locate MSBuild.exe via vswhere, and the fallback path does not exist either: $fallback. Pass -MSBuildPath explicitly."
}
Write-Host "Using fallback MSBuild path: $fallback"
return $fallback
}
$msbuild = Resolve-MSBuildPath -explicitPath $MSBuildPath
Write-Host "Publishing $projectPath (Release, local ClickOnce, updates disabled) to:`n $publishDir"
& $msbuild $projectPath `
-t:Publish `
-p:Configuration=Release `
-p:PublishUrl=$publishDir `
-p:InstallUrl=$publishDir `
-p:PublishDir=$publishDir `
-p:UpdateEnabled=false `
-m
if ($LASTEXITCODE -ne 0) {
throw "MSBuild -t:Publish failed with exit code $LASTEXITCODE"
}
$vstoPath = Join-Path $publishDir 'BulkArchiveMailer.AddIn.vsto'
if (Test-Path $vstoPath) {
Write-Host "`nDone. Installer manifest: $vstoPath"
Write-Host "Copy the whole 'app.publish' folder to the operator's machine and double-click the .vsto file there (see docs/BUILD.md, Способ А)."
} else {
Write-Warning "Publish reported success but $vstoPath was not found - check the MSBuild output above."
}
@@ -0,0 +1,8 @@
Windows Registry Editor Version 5.00
; Полностью убирает регистрацию BulkArchiveMailer из Outlook для текущего
; пользователя (обратное к install-addin.reg). Закройте Outlook перед
; применением. После применения можно вручную удалить папку с файлами
; плагина (например, C:\Tools\BulkArchiveMailer\), если она больше не нужна.
[-HKEY_CURRENT_USER\Software\Microsoft\Office\Outlook\Addins\BulkArchiveMailer.AddIn]
+111 -38
View File
@@ -48,12 +48,67 @@ VSTO/Outlook Interop API, но **первая сборка обязательн
Кнопка ленты **"Массовая рассылка"** появляется на вкладке "Главная"
основного окна Outlook (список писем), в группе "ssh-access-tui".
## Установка вне Visual Studio (ручной sideload)
## Установка вне Visual Studio
Это внутренний инструмент, поэтому здесь используется **sideload через
реестр**, а не полноценная публикация ClickOnce с веб-сервером или
раскатка через GPO (это отдельная задача на будущее, если понадобится
массово ставить плагин большому числу операторов).
Есть два способа поставить собранный плагин оператору. Оба используют один
и тот же build-output и один и тот же самоподписанный тестовый сертификат
(см. пункт про подпись ниже) — разница только в том, как Outlook узнаёт о
плагине.
### Способ А (рекомендуемый): локальная ClickOnce-публикация
Это тот же механизм установки/удаления, что использует практически любой
"нормальный" VSTO/Office-плагин (например, GpgOL для его COM-части) —
пользователь видит стандартный диалог "Установить это приложение?" при
установке и получает штатную запись в **Параметры → Приложения** для
удаления, без ручного редактирования реестра.
1. Из корня `outlook-bulk-sender\` запустите:
```
powershell -File deploy\publish-clickonce.ps1
```
Скрипт сам находит `MSBuild.exe` (сначала через `vswhere.exe`, с
фолбэком на `D:\VS2022\MSBuild\Current\Bin\amd64\MSBuild.exe`, если
vswhere не установлен или ничего не нашёл — путь можно переопределить
параметром `-MSBuildPath`) и запускает эквивалент:
```
MSBuild.exe BulkArchiveMailer.AddIn\BulkArchiveMailer.AddIn.csproj -t:Publish -p:Configuration=Release ^
-p:PublishUrl=C:\путь\к\проекту\BulkArchiveMailer.AddIn\bin\Release\app.publish\ ^
-p:InstallUrl=C:\путь\к\проекту\BulkArchiveMailer.AddIn\bin\Release\app.publish\ ^
-p:PublishDir=bin\Release\app.publish\ -p:UpdateEnabled=false
```
Тот же результат получится и вручную в Visual Studio: правой кнопкой на
`BulkArchiveMailer.AddIn` → **Publish...** → Publish location =
локальная папка (например, `bin\Release\app.publish\`) → Publish —
скрипт просто избавляет от необходимости помнить эти параметры и
набирать длинную команду руками при каждой пересборке инсталлятора.
2. Получится папка `app.publish\` с подпапкой `Application Files\` и
файлом `BulkArchiveMailer.AddIn.vsto` на верхнем уровне. Скопируйте
**всю папку `app.publish\` целиком** (структура важна — `.vsto`
ссылается на файлы в `Application Files\` относительным путём) на
рабочий компьютер оператора — сама папка при этом временная: ClickOnce
копирует файлы в свой собственный per-user кэш при установке, так что
исходную папку после установки можно удалить.
3. На рабочем компьютере — двойной клик по `BulkArchiveMailer.AddIn.vsto`.
Появится стандартный диалог установки Office-приложения (Publisher
будет "неизвестный", т.к. сертификат тестовый — см. ниже); подтвердить
установку.
4. **Удаление**: Параметры Windows → Приложения → Installed apps → найти
`BulkArchiveMailer.AddIn` → Uninstall. Ничего руками в реестре трогать
не нужно.
### Способ Б: ручной sideload через реестр
Более "hacky", но проще для сценариев без интерактивной установки
(автоматизированная раскатка через скрипт/GPO без диалогов). Плагин при
этом запускается прямо из указанной папки на диске, а не из ClickOnce-кэша
— удаление тоже полностью ручное (что и было единственным способом до
того, как способ А выше был проверен на реальной машине).
1. Соберите решение в конфигурации Release (Build → Configuration Manager
→ Release, затем Build Solution).
@@ -63,39 +118,61 @@ VSTO/Outlook Interop API, но **первая сборка обязательн
MSBuild-таргетами VSTO — в исходниках его нет и не должно быть,
`.gitignore` его игнорирует).
3. Скопируйте всю папку `bin\Release\` в постоянное место на диске
оператора (например, `C:\Tools\BulkArchiveMailer\`) — VSTO plug-in
должен запускаться из стабильного пути, а не из папки сборки, которая
может быть пересобрана/удалена.
4. Добавьте ключ реестра, которым Outlook находит плагин:
```
[HKEY_CURRENT_USER\Software\Microsoft\Office\Outlook\Addins\BulkArchiveMailer.AddIn]
"Description"="Массовая рассылка архивов ssh-access-tui"
"FriendlyName"="BulkArchiveMailer"
"Manifest"="file:///C:\\Tools\\BulkArchiveMailer\\BulkArchiveMailer.AddIn.vsto|vstolocal"
"LoadBehavior"=dword:00000003
```
(можно сохранить как `.reg`-файл и применить двойным щелчком).
5. Запустите Outlook. Поскольку манифест не подписан сертификатом
(`SignManifests=false` в `.csproj` — см. комментарий там же о причине),
при первой загрузке Outlook, скорее всего, покажет предупреждение
"не удалось проверить издателя" или подобное. Варианты:
оператора (например, `C:\Tools\BulkArchiveMailer\`) — в отличие от
способа А, здесь плагин должен запускаться из стабильного пути, а не
из папки сборки, которая может быть пересобрана/удалена.
4. Готовые `.reg`-файлы лежат в `outlook-bulk-sender\deploy\`:
`install-addin.reg` добавляет ключ
`HKEY_CURRENT_USER\Software\Microsoft\Office\Outlook\Addins\BulkArchiveMailer.AddIn`
(поправьте путь в `Manifest=`, если папка не `C:\Tools\BulkArchiveMailer\`),
`uninstall-addin.reg` — полностью его удаляет.
5. Запустите Outlook. VSTO не позволяет собрать Application-проект вообще
без подписи ClickOnce-манифеста (`VerifyClickOnceSigningSettings`
в `Microsoft.VisualStudio.Tools.Office.targets` останавливает сборку с
ошибкой, если `SignManifests=false`) — это обнаружилось только при
первой реальной сборке в Visual Studio, а не то решение, которое можно
было предугадать заранее. Поэтому в `BulkArchiveMailer.AddIn\` лежит
`BulkArchiveMailer.AddIn_TemporaryKey.pfx` — одноразовый самоподписанный
сертификат без пароля (`SignManifests=true`,
`ManifestCertificateThumbprint` в `.csproj`), сгенерированный ровно так
же, как это делает сама Visual Studio по кнопке "Create Test
Certificate..." на вкладке Signing. Он нужен только чтобы
удовлетворить это требование сборки и **не** делает плагин доверенным:
сертификат не от доверенного центра сертификации, поэтому Outlook
при первой загрузке всё равно, скорее всего, покажет предупреждение
"не удалось проверить издателя" или подобное. Варианты те же:
- принять предупреждение вручную (для одного оператора это разумно);
- добавить папку с плагином в доверенные расположения для VSTO:
`HKEY_CURRENT_USER\Software\Microsoft\VSTO\Security\Trust Manager\TrustedFolders\<любой уникальный ключ>` = путь к папке с `.vsto`, `String` REG_SZ.
- для более широкой раскатки — подписать манифест сертификатом
(Project Properties → Signing в Visual Studio) и настроить
доверие централизованно (GPO/сертификат в доверенных издателях) —
это уже упомянутое будущее развитие, не сделано в этой версии.
- для более широкой раскатки — подписать манифест сертификатом от
реального доверенного ЦС (Project Properties → Signing в Visual
Studio) и настроить доверие централизованно (GPO/сертификат в
доверенных издателях) — это уже упомянутое будущее развитие, не
сделано в этой версии.
## Типичные проблемы при первом открытии
Поскольку `.csproj`/`.sln` в этом решении написаны вручную (без мастера
Visual Studio, из-за отсутствия здесь Windows/VS/.NET SDK для
генерации), возможны мелкие расхождения с тем, что сгенерировал бы
реальный мастер "Outlook VSTO Add-in". Если сборка `BulkArchiveMailer.AddIn`
падает на ссылках `Microsoft.Office.*`/`Microsoft.VisualStudio.Tools.*`:
`BulkArchiveMailer.AddIn` был впервые собран вживую (Visual Studio 2022,
workload "Office/SharePoint development", `MSBuild.exe` — см. команду
сборки выше) после того, как `.csproj`/`.sln` какое-то время были написаны
вручную без доступа к Windows/VS. Первая реальная сборка нашла и потребовала
исправить два расхождения с настоящим VSTO API (уже исправлено в текущей
версии, здесь — для истории и на случай похожих проблем при доработке):
- `ThisAddIn`/`ThisAddIn.Designer.cs` наследовались от
`Microsoft.Office.Tools.Outlook.OutlookAddIn` — на практике это
интерфейс (только члены FormRegion), а не базовый класс. Реальный
конкретный базовый класс — `Microsoft.Office.Tools.Outlook.OutlookAddInBase`
из отдельной сборки `Microsoft.Office.Tools.Outlook.v4.0.Utilities.dll`
(ссылка `Private=True`, как в стандартном шаблоне "Outlook VSTO
Add-in" — см. `.csproj`).
- VSTO Application-проект не собирается вообще без подписи ClickOnce
манифеста (`SignManifests=false` раньше валил сборку с ошибкой
`VerifyClickOnceSigningSettings`) — отсюда одноразовый тестовый
сертификат `BulkArchiveMailer.AddIn_TemporaryKey.pfx`, см. пункт 5 выше.
Если сборка всё же падает на ссылках `Microsoft.Office.*`/
`Microsoft.VisualStudio.Tools.*` на другой машине:
1. Проверьте, что workload "Office/SharePoint development" точно
установлен (Visual Studio Installer → Modify).
@@ -112,10 +189,6 @@ Visual Studio, из-за отсутствия здесь Windows/VS/.NET SDK д
удалите атрибут версии в Solution Explorer и добавьте заново через
Add Reference — Visual Studio подставит то, что реально стоит на
машине.
4. TargetFrameworkVersion `v4.8` требует установленного .NET Framework 4.8
Developer Pack — Visual Studio предложит установить его сама при
4. TargetFrameworkVersion `v4.8.1` требует установленного .NET Framework
4.8.1 Developer Pack — Visual Studio предложит установить его сама при
открытии, если его нет.
Ничего из перечисленного не проверялось вживую — список составлен по
опыту работы с VSTO-проектами в целом, а не по факту увиденной ошибки в
этом конкретном решении.