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 0000000..2c58e5f
Binary files /dev/null and b/outlook-bulk-sender/BulkArchiveMailer.AddIn/BulkArchiveMailer.AddIn_TemporaryKey.pfx differ
diff --git a/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs
index fd7ba73..ac47ba2 100644
--- a/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs
+++ b/outlook-bulk-sender/BulkArchiveMailer.AddIn/Globals.cs
@@ -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).
+ ///
+ /// Microsoft.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-проектами в целом, а не по факту увиденной ошибки в
-этом конкретном решении.