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:
1 parent
7f0e0fdb23
commit
643becad15
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)' != ''" />
|
||||
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
-11
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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}",
|
||||
|
||||
@@ -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]
|
||||
@@ -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-проектами в целом, а не по факту увиденной ошибки в
|
||||
этом конкретном решении.
|
||||
Reference in new issue
Block a user