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>
187 lines
7.4 KiB
C#
187 lines
7.4 KiB
C#
using System;
|
|
using System.Runtime.InteropServices;
|
|
using BulkArchiveMailer.Core;
|
|
using Outlook = Microsoft.Office.Interop.Outlook;
|
|
|
|
namespace BulkArchiveMailer.AddIn
|
|
{
|
|
/// <summary>Per-job result of a send attempt, returned by MailSender so the caller can log + report it without any of this class's own exceptions ever escaping.</summary>
|
|
public sealed class MailSendOutcome
|
|
{
|
|
public SendJob Job { get; set; }
|
|
public bool Success { get; set; }
|
|
public string ErrorMessage { get; set; }
|
|
public bool WasDraft { get; set; }
|
|
}
|
|
|
|
/// <summary>
|
|
/// Creates and sends (or, in test mode, saves as a Draft) one Outlook
|
|
/// mail item per SendJob via Interop. This is the only file that
|
|
/// actually creates/sends a MailItem - everything else builds up to
|
|
/// calling SendOne.
|
|
///
|
|
/// SendOne never throws: every failure is caught and reported back as
|
|
/// a failed MailSendOutcome, so a caller looping over a batch can
|
|
/// always move on to the next job without a try/catch of its own.
|
|
/// </summary>
|
|
public sealed class MailSender
|
|
{
|
|
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">
|
|
/// HTML of the operator's default signature, already read from
|
|
/// disk by SignatureReader (Outlook does not auto-insert a
|
|
/// signature on programmatically created items - it must be
|
|
/// appended to HTMLBody by hand). May be null/empty if no
|
|
/// signature is configured; the mail is then sent without one.
|
|
/// </param>
|
|
/// <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>
|
|
/// <param name="testMode">When true, the mail item is Save()'d (left in Drafts) instead of Send()'d - the safety net for trying the tool out before trusting it with real recipients.</param>
|
|
public MailSendOutcome SendOne(SendJob job, bool testMode)
|
|
{
|
|
if (job == null)
|
|
{
|
|
throw new ArgumentNullException(nameof(job));
|
|
}
|
|
|
|
Outlook.MailItem mailItem = null;
|
|
try
|
|
{
|
|
mailItem = (Outlook.MailItem)application.CreateItem(Outlook.OlItemType.olMailItem);
|
|
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))
|
|
{
|
|
body += "<br><br>" + signatureHtml;
|
|
}
|
|
|
|
mailItem.HTMLBody = body;
|
|
|
|
if (!string.IsNullOrEmpty(job.ArchivePath))
|
|
{
|
|
mailItem.Attachments.Add(job.ArchivePath, Outlook.OlAttachmentType.olByValue, Type.Missing, Type.Missing);
|
|
}
|
|
|
|
if (testMode)
|
|
{
|
|
mailItem.Save();
|
|
}
|
|
else
|
|
{
|
|
mailItem.Send();
|
|
}
|
|
|
|
return new MailSendOutcome { Job = job, Success = true, WasDraft = testMode };
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
return new MailSendOutcome { Job = job, Success = false, ErrorMessage = ex.Message, WasDraft = testMode };
|
|
}
|
|
finally
|
|
{
|
|
if (mailItem != null)
|
|
{
|
|
Marshal.ReleaseComObject(mailItem);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// <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.
|
|
}
|
|
}
|
|
}
|
|
}
|