From 264ed74095e5e34a34e9e164081a00c2e29bf478 Mon Sep 17 00:00:00 2001 From: redlineo Date: Fri, 24 Jul 2026 09:51:52 +0300 Subject: [PATCH] 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 --- .../BulkArchiveMailer.AddIn.csproj | 75 +++++++-- .../BulkArchiveMailer.AddIn_TemporaryKey.pfx | Bin 0 -> 2564 bytes .../BulkArchiveMailer.AddIn/Globals.cs | 44 +++++- .../BulkArchiveMailer.AddIn/MailSender.cs | 89 ++++++++++- .../OutlookAccounts.cs | 77 +++++++++ .../ThisAddIn.Designer.cs | 66 ++++++-- .../BulkArchiveMailer.AddIn/ThisAddIn.cs | 15 ++ .../BulkArchiveMailer.AddIn/UI/PreviewForm.cs | 2 +- .../UI/SettingsForm.Designer.cs | 18 +++ .../UI/SettingsForm.cs | 58 +++++++ .../AppSettingsTests.cs | 73 +++++++++ .../BulkArchiveMailer.Core.Tests.csproj | 2 +- .../BulkArchiveMailer.Core/AppSettings.cs | 25 +++ .../BulkArchiveMailer.Core.csproj | 2 +- .../config.default.json | 1 + outlook-bulk-sender/README.md | 4 + outlook-bulk-sender/deploy/install-addin.reg | 12 ++ .../deploy/publish-clickonce.ps1 | 103 ++++++++++++ .../deploy/uninstall-addin.reg | 8 + outlook-bulk-sender/docs/BUILD.md | 149 +++++++++++++----- 20 files changed, 756 insertions(+), 67 deletions(-) create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn_TemporaryKey.pfx create mode 100644 outlook-bulk-sender/BulkArchiveMailer.AddIn/OutlookAccounts.cs create mode 100644 outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs create mode 100644 outlook-bulk-sender/deploy/install-addin.reg create mode 100644 outlook-bulk-sender/deploy/publish-clickonce.ps1 create mode 100644 outlook-bulk-sender/deploy/uninstall-addin.reg diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj index 66b234e..0a46ebf 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn.csproj @@ -12,7 +12,7 @@ Library BulkArchiveMailer.AddIn BulkArchiveMailer.AddIn - v4.8 + v4.8.1 512 {BAA0C2D2-18E2-41B9-852F-F413020CAA33};{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC} 7.3 @@ -25,19 +25,47 @@ true + BulkArchiveMailer — массовая рассылка архивов + ssh-access-tui + + false - false + true + BulkArchiveMailer.AddIn_TemporaryKey.pfx + 9A9AF6F0E6EF09093910BD0FA2CCFFEC81F00E25 true @@ -95,6 +123,19 @@ False + + + False + True + False @@ -111,6 +152,7 @@ + @@ -136,6 +178,15 @@ + + diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn_TemporaryKey.pfx b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn_TemporaryKey.pfx new file mode 100644 index 0000000000000000000000000000000000000000..2c58e5f9ade2c518e70061f63818024d7b4c8709 GIT binary patch literal 2564 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.Office.Tools.Outlook.OutlookAddIn 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 below, exactly + /// like the real VSTO-generated Globals class does via its + /// Globals.ThisAddIn = this; assignment. /// 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(); + } + } + } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs index 31e06df..3ff1ec1 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/MailSender.cs @@ -28,6 +28,7 @@ namespace BulkArchiveMailer.AddIn { private readonly Outlook.Application application; private readonly string signatureHtml; + private readonly string fromAccountSmtpAddress; /// Live Outlook.Application (from ThisAddIn.Application) used to create mail items. /// @@ -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. /// - public MailSender(Outlook.Application application, string signatureHtml) + /// + /// 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. + /// + 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; } /// Row to send. Caller is responsible for only passing selectable (Ready/AlreadySent), checked rows - this method does not re-validate Status. @@ -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 } } } + + /// + /// 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. + /// + 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. + } + } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/OutlookAccounts.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/OutlookAccounts.cs new file mode 100644 index 0000000..3340938 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/OutlookAccounts.cs @@ -0,0 +1,77 @@ +using System; +using System.Collections.Generic; +using System.Runtime.InteropServices; +using Outlook = Microsoft.Office.Interop.Outlook; + +namespace BulkArchiveMailer.AddIn +{ + /// 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. + public sealed class OutlookAccountInfo + { + public string DisplayName { get; set; } = ""; + public string SmtpAddress { get; set; } = ""; + } + + /// + /// 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. + /// + public static class OutlookAccounts + { + public static List ListAccounts(Outlook.Application application) + { + var result = new List(); + 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(); + } + 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; + } + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.Designer.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.Designer.cs index 1538c6b..e8e4d91 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.Designer.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.Designer.cs @@ -1,24 +1,68 @@ namespace BulkArchiveMailer.AddIn { /// - /// 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 Microsoft.Office.Tools.Outlook.OutlookAddIn + /// interface (that only declares the FormRegion-related members) but + /// Microsoft.Office.Tools.Outlook.OutlookAddInBase - 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. /// - 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 { + /// Populated from the host item in ; exposed to the rest of the add-in via the OutlookApplication alias in ThisAddIn.cs. + internal Microsoft.Office.Interop.Outlook.Application Application; + + /// + /// 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). + /// + public ThisAddIn(Microsoft.Office.Tools.Outlook.Factory factory, System.IServiceProvider serviceProvider) + : base(factory, serviceProvider, "AddIn", "ThisAddIn") + { + Globals.Factory = factory; + } + /// /// Required method for Designer support - do not modify the /// contents of this method with the code editor. /// - 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( + typeof(Microsoft.Office.Interop.Outlook.Application), "Application"); + Globals.ThisAddIn = this; + global::System.Windows.Forms.Application.EnableVisualStyles(); + } + + /// + /// Required method for Designer support - do not modify the + /// contents of this method with the code editor. + /// + protected override void FinishInitialization() + { + this.InternalStartup(); + this.OnStartup(); } } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.cs index 4d9ac47..f5eaf4c 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/ThisAddIn.cs @@ -28,5 +28,20 @@ namespace BulkArchiveMailer.AddIn { return new RibbonController(); } + + #region VSTO generated code + + /// + /// 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. + /// + private void InternalStartup() + { + this.Startup += new EventHandler(ThisAddIn_Startup); + this.Shutdown += new EventHandler(ThisAddIn_Shutdown); + } + + #endregion } } diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs index e1dbd92..6d73090 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/PreviewForm.cs @@ -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; diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs index 74f0aa6..132100a 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.Designer.cs @@ -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 // diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs index 21fbaeb..1d65e19 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/UI/SettingsForm.cs @@ -42,6 +42,16 @@ namespace BulkArchiveMailer.AddIn.UI PopulateFrom(settings); } + /// 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". + 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; } + /// + /// 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. + /// + 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 }); diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs new file mode 100644 index 0000000..1410682 --- /dev/null +++ b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/AppSettingsTests.cs @@ -0,0 +1,73 @@ +using System.Reflection; +using BulkArchiveMailer.Core; +using Xunit; + +namespace BulkArchiveMailer.Core.Tests +{ + /// + /// 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. + /// + 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 }); + } + } +} diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/BulkArchiveMailer.Core.Tests.csproj b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/BulkArchiveMailer.Core.Tests.csproj index 57f64c9..94e307b 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/BulkArchiveMailer.Core.Tests.csproj +++ b/outlook-bulk-sender/BulkArchiveMailer.Core.Tests/BulkArchiveMailer.Core.Tests.csproj @@ -12,7 +12,7 @@ --> - net48 + net481 false 7.3 diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs index 7eeacda..b294847 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/AppSettings.cs @@ -45,6 +45,31 @@ namespace BulkArchiveMailer.Core [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; } = ""; + [DataMember(Name = "templates")] public TemplateSetData Templates { get; set; } = new TemplateSetData(); diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj b/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj index b9f8195..0d17404 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/BulkArchiveMailer.Core.csproj @@ -8,7 +8,7 @@ Library BulkArchiveMailer.Core BulkArchiveMailer.Core - v4.8 + v4.8.1 512 true 7.3 diff --git a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json index 2527e58..59838d5 100644 --- a/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json +++ b/outlook-bulk-sender/BulkArchiveMailer.Core/config.default.json @@ -3,6 +3,7 @@ "emailDomain": "redlab.lo", "testModeDefault": true, "signatureHtmlPath": "", + "fromAccountSmtpAddress": "", "templates": { "ssh_access": { "subject": "Доступ к SSH jump-хосту — {Login}", diff --git a/outlook-bulk-sender/README.md b/outlook-bulk-sender/README.md index b24405d..f6d61a6 100644 --- a/outlook-bulk-sender/README.md +++ b/outlook-bulk-sender/README.md @@ -89,6 +89,10 @@ Outlook в принципе не запустить. подпись определяется автоматически по умолчанию Outlook для новых писем) либо указать конкретный файл вручную — например, если у оператора настроена не та подпись, которую нужно вставлять в такие письма. +- **Отправлять от** — выпадающий список аккаунтов текущей сессии Outlook + (`Session.Accounts`), полезно, если у оператора несколько ящиков (например, + отдельный служебный/shared-ящик именно для этой рассылки). Пункт + "(по умолчанию Outlook)" — прежнее поведение без явного выбора аккаунта. - **Шаблоны писем** — тема и HTML-тело отдельно для `ssh_access` и `config_update`, с плейсхолдерами `{Login}`, `{Email}`, `{Date}`. - **Тестовый режим по умолчанию** — включён по умолчанию (письма сохраняются diff --git a/outlook-bulk-sender/deploy/install-addin.reg b/outlook-bulk-sender/deploy/install-addin.reg new file mode 100644 index 0000000..e7e89e9 --- /dev/null +++ b/outlook-bulk-sender/deploy/install-addin.reg @@ -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 diff --git a/outlook-bulk-sender/deploy/publish-clickonce.ps1 b/outlook-bulk-sender/deploy/publish-clickonce.ps1 new file mode 100644 index 0000000..ce2cc61 --- /dev/null +++ b/outlook-bulk-sender/deploy/publish-clickonce.ps1 @@ -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=\BulkArchiveMailer.AddIn\bin\Release\app.publish\ + -p:InstallUrl=\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." +} diff --git a/outlook-bulk-sender/deploy/uninstall-addin.reg b/outlook-bulk-sender/deploy/uninstall-addin.reg new file mode 100644 index 0000000..12a7430 --- /dev/null +++ b/outlook-bulk-sender/deploy/uninstall-addin.reg @@ -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] diff --git a/outlook-bulk-sender/docs/BUILD.md b/outlook-bulk-sender/docs/BUILD.md index 9e59ba3..0b87e25 100644 --- a/outlook-bulk-sender/docs/BUILD.md +++ b/outlook-bulk-sender/docs/BUILD.md @@ -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-проектами в целом, а не по факту увиденной ошибки в -этом конкретном решении.