feat(compose): Task #5 — Compose from Case + URL protocol + auto-file

UI:
- ComposeFromCaseDialog (RTL, search-then-pick a single Case via
  GlobalSearch + entityType=Case filter)
- ComposeFromCaseViewModel — debounced GlobalSearch, returns chosen
  EspoEntityRef

Host:
- ComposeService.ApplyCaseToMailAsync:
  * fetches Case + primary Contact
  * stamps UserProperties EspoCaseId, EspoCaseName, OutlookAddinGuid
  * pre-fills To/Subject and appends an RTL footer with a CRM deep link
- ItemSend hook (in AddInHost.HookItemSend): when the user sends a
  tagged compose, register guid → target with SentItemsWatcher
- SentItemsWatcher: rooted Items reference (prevents GC), 30s expiry
  sweep, 2-min default timeout per pending tag, MailMatched event
- AddInHost.OnSentMailMatched: extracts the sent MailItem, files via
  FilingService, stamps "Filed: {name}" category
- LaunchComposeFromCaseAsync (Inspector ribbon) and
  LaunchComposeFromCaseByIdAsync (URL protocol) entry points
- NamedPipeListener (\.\pipe\MarcusLaw.OutlookAddin) — listens for
  "COMPOSE <caseId>" lines, dispatches on WPF Dispatcher

Ribbon:
- InspectorRibbon.xml — "כתוב מתיק" button on Mail Compose ribbon, hidden
  for received/read mail via getVisible
- ExplorerRibbon class now serves both Explorer and Inspector ribbons
  (single IRibbonExtensibility, dispatches by ribbonID)

URL protocol:
- New OutlookAddinProtocolHandler console project (net48 WinExe):
  parses outlookaddin://compose?caseId=<id>, connects to named pipe,
  writes "COMPOSE <id>\n"
- tools/install-protocol.ps1 + uninstall-protocol.ps1 register the
  scheme under HKCU\Software\Classes\outlookaddin

Core/UI/Tests + protocol handler all build; 38 unit tests still green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 15:47:36 +03:00
parent 2368764251
commit 0964439441
15 changed files with 1123 additions and 12 deletions
+6
View File
@@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin.Tests", "src\O
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin", "src\OutlookAddin\OutlookAddin.csproj", "{7A8B6509-CB21-42BD-A42C-2735170AFFD2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddinProtocolHandler", "src\OutlookAddinProtocolHandler\OutlookAddinProtocolHandler.csproj", "{A0000000-0000-0000-0000-000000000005}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -32,6 +34,10 @@ Global
{7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Release|Any CPU.Build.0 = Release|Any CPU
{A0000000-0000-0000-0000-000000000005}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A0000000-0000-0000-0000-000000000005}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A0000000-0000-0000-0000-000000000005}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A0000000-0000-0000-0000-000000000005}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -0,0 +1,62 @@
<Window x:Class="MarcusLaw.OutlookAddin.UI.Dialogs.ComposeFromCaseDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="כתוב מייל מתיק"
Height="450" Width="560"
MinHeight="320" MinWidth="500"
FlowDirection="RightToLeft"
WindowStartupLocation="CenterOwner"
ShowInTaskbar="False"
ResizeMode="CanResize">
<Grid Margin="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<DockPanel Grid.Row="0" Margin="0,0,0,8" LastChildFill="True">
<TextBlock DockPanel.Dock="Right" Text="חפש תיק:" VerticalAlignment="Center" Margin="0,0,8,0" />
<TextBox x:Name="SearchBox"
Text="{Binding SearchText, UpdateSourceTrigger=PropertyChanged}"
Padding="6,4"
FontSize="14" />
</DockPanel>
<Border Grid.Row="1" BorderBrush="#CCC" BorderThickness="1" CornerRadius="3">
<ListBox ItemsSource="{Binding Cases}"
SelectedItem="{Binding SelectedCase}"
BorderThickness="0">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Margin="2,3">
<TextBlock>
<Run Text="[" /><Run Text="{Binding Number, Mode=OneWay}" /><Run Text="] " />
<Run Text="{Binding Name, Mode=OneWay}" FontWeight="SemiBold" />
</TextBlock>
<TextBlock Text="{Binding Status}" Foreground="Gray" FontSize="10" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Border>
<TextBlock Grid.Row="2" Text="{Binding StatusMessage}"
Foreground="Gray" Margin="0,6,0,0" TextWrapping="Wrap" />
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Left" Margin="0,8,0,0">
<Button Command="{Binding SubmitCommand}"
IsDefault="True"
Content="המשך"
Padding="20,5"
Margin="0,0,8,0"
MinWidth="100" />
<Button Command="{Binding CancelCommand}"
IsCancel="True"
Content="ביטול"
Padding="20,5"
MinWidth="100" />
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,36 @@
using System;
using System.Windows;
using MarcusLaw.OutlookAddin.UI.ViewModels;
namespace MarcusLaw.OutlookAddin.UI.Dialogs
{
public partial class ComposeFromCaseDialog : Window
{
private ComposeFromCaseViewModel? _viewModel;
public ComposeFromCaseDialog()
{
InitializeComponent();
Loaded += (_, __) => SearchBox.Focus();
}
public ComposeFromCaseDialog(ComposeFromCaseViewModel viewModel) : this()
{
AttachViewModel(viewModel);
}
public void AttachViewModel(ComposeFromCaseViewModel viewModel)
{
if (_viewModel != null) _viewModel.RequestClose -= OnRequestClose;
_viewModel = viewModel ?? throw new ArgumentNullException(nameof(viewModel));
DataContext = _viewModel;
_viewModel.RequestClose += OnRequestClose;
}
private void OnRequestClose(object? sender, EventArgs e)
{
DialogResult = _viewModel?.DialogResult;
Close();
}
}
}
@@ -0,0 +1,127 @@
using System;
using System.Collections.ObjectModel;
using System.Threading;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// Picks a single Case via the EspoCRM GlobalSearch API. Returns the
/// chosen <see cref="EspoEntityRef"/> so the host can fetch full details
/// (contacts, account, etc.) and populate the open MailItem.
/// </summary>
public sealed partial class ComposeFromCaseViewModel : ObservableObject
{
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
private CancellationTokenSource? _searchCts;
[ObservableProperty]
private string searchText = string.Empty;
[ObservableProperty]
private CaseEntity? selectedCase;
[ObservableProperty]
private string? statusMessage;
public ObservableCollection<CaseEntity> Cases { get; } = new ObservableCollection<CaseEntity>();
public EspoEntityRef? Result { get; private set; }
public bool? DialogResult { get; private set; }
public event EventHandler? RequestClose;
public ComposeFromCaseViewModel(IEspoCrmClient client, ILogger logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
partial void OnSearchTextChanged(string value)
{
_searchCts?.Cancel();
_searchCts = new CancellationTokenSource();
_ = SearchAsync(value, _searchCts.Token);
}
partial void OnSelectedCaseChanged(CaseEntity? value)
{
SubmitCommand.NotifyCanExecuteChanged();
}
private async Task SearchAsync(string query, CancellationToken ct)
{
try
{
await Task.Delay(250, ct).ConfigureAwait(true);
var trimmed = (query ?? string.Empty).Trim();
if (trimmed.Length < 2)
{
Cases.Clear();
StatusMessage = null;
return;
}
var search = await _client.GlobalSearchAsync(trimmed, ct).ConfigureAwait(true);
ct.ThrowIfCancellationRequested();
Cases.Clear();
int kept = 0;
foreach (var hit in search.List)
{
if (!string.Equals(hit.EntityType, "Case", StringComparison.OrdinalIgnoreCase)) continue;
Cases.Add(new CaseEntity
{
Id = hit.Id,
Name = hit.Name,
});
kept++;
}
StatusMessage = kept == 0
? "אין תוצאות מסוג תיק (Case)"
: $"נמצאו {kept} תיקים";
}
catch (OperationCanceledException) { /* superseded */ }
catch (EspoCrmAuthorizationException)
{
StatusMessage = "מפתח ה-API נדחה — יש לעדכן בהגדרות";
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeFromCase search failed");
StatusMessage = "שגיאת חיפוש: " + ex.Message;
}
}
[RelayCommand(CanExecute = nameof(CanSubmit))]
private void Submit()
{
if (SelectedCase == null) return;
Result = new EspoEntityRef
{
Id = SelectedCase.Id,
Name = SelectedCase.Name,
EntityType = "Case"
};
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
}
private bool CanSubmit() => SelectedCase != null && !string.IsNullOrWhiteSpace(SelectedCase.Id);
[RelayCommand]
private void Cancel()
{
DialogResult = false;
RequestClose?.Invoke(this, EventArgs.Empty);
}
}
}
+12
View File
@@ -232,18 +232,30 @@
<EmbeddedResource Include="Ribbon\ExplorerRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.ExplorerRibbon.xml</LogicalName>
</EmbeddedResource>
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
</EmbeddedResource>
<Compile Include="Services\AddInHost.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\ComposeService.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\ExplorerSelectionHandler.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\MailItemExtractor.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\NamedPipeListener.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\RetryQueueProcessor.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\SentItemsWatcher.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\SidebarController.cs">
<SubType>Code</SubType>
</Compile>
+68 -12
View File
@@ -7,36 +7,44 @@ using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookAddin.Ribbon
{
/// <summary>
/// Single <see cref="IRibbonExtensibility"/> implementation that serves
/// both the Explorer ribbon (Inbox / list view) and the Inspector ribbon
/// (compose window) — VSTO calls <see cref="GetCustomUI"/> with a
/// ribbonID identifying which host is asking.
/// </summary>
[ComVisible(true)]
public class ExplorerRibbon : IRibbonExtensibility
{
private const string ResourceName = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string ButtonId = "FileToEspoCrm";
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
private const string InspectorResource = "OutlookAddin.Ribbon.InspectorRibbon.xml";
private const string FileButtonId = "FileToEspoCrm";
private IRibbonUI? _ribbon;
private IRibbonUI? _explorerRibbon;
private IRibbonUI? _inspectorRibbon;
public string GetCustomUI(string ribbonID)
{
if (ribbonID != "Microsoft.Outlook.Explorer") return string.Empty;
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName))
switch (ribbonID)
{
if (stream == null) return string.Empty;
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
case "Microsoft.Outlook.Explorer":
return LoadResource(ExplorerResource);
case "Microsoft.Outlook.Mail.Compose":
return LoadResource(InspectorResource);
default:
return string.Empty;
}
}
public void OnRibbonLoad(IRibbonUI ribbon)
{
_ribbon = ribbon;
_explorerRibbon = ribbon;
try
{
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
if (explorer != null)
{
explorer.SelectionChange += () => _ribbon?.InvalidateControl(ButtonId);
explorer.SelectionChange += () => _explorerRibbon?.InvalidateControl(FileButtonId);
}
}
catch
@@ -45,6 +53,11 @@ namespace OutlookAddin.Ribbon
}
}
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
{
_inspectorRibbon = ribbon;
}
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
{
try
@@ -72,5 +85,48 @@ namespace OutlookAddin.Ribbon
{
Globals.ThisAddIn.AddInHost.LaunchSettings();
}
public bool OnGetComposeFromCaseVisible(IRibbonControl control)
{
// Show only on outgoing (compose) mail inspectors.
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is Outlook.MailItem mail)
{
return mail.Sent == false;
}
}
catch { /* fall through */ }
return true;
}
public void OnComposeFromCase(IRibbonControl control)
{
try
{
var inspector = control?.Context as Outlook.Inspector;
if (inspector?.CurrentItem is Outlook.MailItem mail)
{
_ = Globals.ThisAddIn.AddInHost.LaunchComposeFromCaseAsync(mail);
}
}
catch (Exception ex)
{
Globals.ThisAddIn.AddInHost.Logger.Warning(ex, "OnComposeFromCase failed");
}
}
private static string LoadResource(string name)
{
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(name))
{
if (stream == null) return string.Empty;
using (var reader = new StreamReader(stream))
{
return reader.ReadToEnd();
}
}
}
}
}
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<customUI xmlns="http://schemas.microsoft.com/office/2009/07/customui" onLoad="OnInspectorRibbonLoad">
<ribbon>
<tabs>
<tab idMso="TabNewMailMessage">
<group id="MarcusLawComposeGroup" label="Marcus-Law" insertBeforeMso="GroupClipboard">
<button id="ComposeFromCase"
label="כתוב מתיק"
screentip="פתח את המייל מתיק EspoCRM"
supertip="בחר תיק EspoCRM כדי לאכלס את הנמענים ואת הכותרת של המייל הזה, וכדי שהמייל יתויק אוטומטית כששולחים אותו."
size="large"
imageMso="MailMergeStartLetters"
onAction="OnComposeFromCase"
getVisible="OnGetComposeFromCaseVisible" />
</group>
</tab>
</tabs>
</ribbon>
</customUI>
+215
View File
@@ -37,12 +37,15 @@ namespace OutlookAddin.Services
public IEspoCrmClient? CrmClient { get; private set; }
public IFilingService? FilingService { get; private set; }
public IMatchingService? MatchingService { get; private set; }
public ComposeService? ComposeService { get; private set; }
private readonly Outlook.Application _outlookApp;
private readonly MemoryCache _matchCache = new MemoryCache(new MemoryCacheOptions());
private RetryQueueProcessor? _retryProcessor;
private HttpClient? _httpClient;
private SidebarController? _sidebarController;
private SentItemsWatcher? _sentItemsWatcher;
private NamedPipeListener? _pipeListener;
public AddInHost(Outlook.Application outlookApp)
{
@@ -56,6 +59,139 @@ namespace OutlookAddin.Services
RetryQueue = new DiskRetryQueue(Logger);
TryInitializeCrm();
InitializeSentItemsWatcher();
InitializeNamedPipeListener();
HookItemSend();
}
private void InitializeSentItemsWatcher()
{
try
{
_sentItemsWatcher = new SentItemsWatcher(_outlookApp, Logger);
_sentItemsWatcher.MailMatched += OnSentMailMatched;
_sentItemsWatcher.Initialize();
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: SentItemsWatcher init failed");
}
}
private void InitializeNamedPipeListener()
{
try
{
_pipeListener = new NamedPipeListener(System.Windows.Threading.Dispatcher.CurrentDispatcher, Logger);
_pipeListener.MessageReceived += OnPipeMessage;
_pipeListener.Start();
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: NamedPipeListener init failed");
}
}
private void HookItemSend()
{
try
{
_outlookApp.ItemSend += OnItemSend;
}
catch (Exception ex)
{
Logger.Error(ex, "AddInHost: ItemSend hook failed");
}
}
private void OnItemSend(object item, ref bool cancel)
{
try
{
if (item is not Outlook.MailItem mail) return;
var guid = ComposeService.ReadAddInGuid(mail);
var caseId = ComposeService.ReadEspoCaseId(mail);
if (string.IsNullOrWhiteSpace(guid) || string.IsNullOrWhiteSpace(caseId)) return;
var name = ComposeService.ReadEspoCaseName(mail) ?? caseId!;
var target = new FilingTarget("Case", caseId!, name);
_sentItemsWatcher?.Track(guid!, target);
Logger.Information("Outgoing mail tagged for auto-file (guid={Guid}, caseId={CaseId})", guid, caseId);
}
catch (Exception ex)
{
Logger.Warning(ex, "AddInHost.OnItemSend handler failed");
}
}
private async void OnSentMailMatched(object? sender, SentMailMatchedEventArgs e)
{
try
{
if (FilingService == null)
{
Logger.Warning("Auto-file aborted — FilingService not configured");
return;
}
var envelope = MailItemExtractor.Extract(e.Mail);
var summary = await FilingService.FileAsync(new[] { envelope }, e.Target).ConfigureAwait(true);
if (summary.FiledCount == 1)
{
StampSingleCategory(e.Mail, "Filed: " + e.Target.ParentName);
Logger.Information("Auto-filed sent mail to {Type}/{Id}", e.Target.ParentType, e.Target.ParentId);
}
else if (summary.QueuedCount == 1)
{
Logger.Information("Auto-file queued for retry (target={Name})", e.Target.ParentName);
}
else
{
Logger.Warning("Auto-file failed for sent mail (target={Name})", e.Target.ParentName);
}
}
catch (Exception ex)
{
Logger.Warning(ex, "OnSentMailMatched handler failed");
}
}
private void StampSingleCategory(Outlook.MailItem mail, string categoryName)
{
try
{
EnsureCategoryExists(categoryName);
var existing = mail.Categories ?? string.Empty;
if (existing.IndexOf(categoryName, StringComparison.Ordinal) >= 0) return;
mail.Categories = string.IsNullOrEmpty(existing) ? categoryName : existing + "; " + categoryName;
mail.Save();
}
catch (Exception ex)
{
Logger.Warning(ex, "StampSingleCategory failed");
}
}
private void OnPipeMessage(object? sender, string message)
{
// Message format (UTF-8 single line):
// COMPOSE <caseId>
// Future commands may follow.
try
{
if (string.IsNullOrWhiteSpace(message)) return;
var trimmed = message.Trim();
if (trimmed.StartsWith("COMPOSE ", StringComparison.OrdinalIgnoreCase))
{
var caseId = trimmed.Substring("COMPOSE ".Length).Trim();
if (!string.IsNullOrWhiteSpace(caseId))
{
_ = LaunchComposeFromCaseByIdAsync(caseId);
}
}
}
catch (Exception ex)
{
Logger.Warning(ex, "OnPipeMessage handler failed for {Message}", message);
}
}
/// <summary>
@@ -137,6 +273,7 @@ namespace OutlookAddin.Services
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
ComposeService = new ComposeService(CrmClient, Logger);
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
_retryProcessor.Start();
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
@@ -159,6 +296,81 @@ namespace OutlookAddin.Services
CrmClient = null;
FilingService = null;
MatchingService = null;
ComposeService = null;
}
/// <summary>
/// Entry point invoked by the Inspector "Compose from Case" ribbon
/// button. The MailItem comes from the open Inspector — populate it
/// with case details and stamp the tracking UserProperties.
/// </summary>
public async Task LaunchComposeFromCaseAsync(Outlook.MailItem mail)
{
if (mail == null) return;
if (!IsConfigured)
{
MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
var vm = new ComposeFromCaseViewModel(CrmClient!, Logger);
var dialog = new ComposeFromCaseDialog(vm);
var ok = dialog.ShowDialog();
if (ok != true || vm.Result == null) return;
try
{
await ComposeService!.ApplyCaseToMailAsync(mail, vm.Result.Id, Settings.EspoCrmUrl).ConfigureAwait(true);
}
catch (Exception ex)
{
Logger.Error(ex, "LaunchComposeFromCaseAsync failed");
MessageBox.Show(
"טעינת התיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
/// <summary>
/// Entry point for the URL-protocol handler (outlookaddin://compose?caseId=…).
/// Creates a brand new MailItem, populates it with case details, then
/// shows the Inspector so the user can compose+send.
/// </summary>
public async Task LaunchComposeFromCaseByIdAsync(string caseId)
{
if (!IsConfigured)
{
MessageBox.Show(
"התוסף עדיין לא מוגדר. פתח את ההגדרות תחילה.",
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Information);
return;
}
Outlook.MailItem? mail = null;
try
{
mail = (Outlook.MailItem)_outlookApp.CreateItem(Outlook.OlItemType.olMailItem);
await ComposeService!.ApplyCaseToMailAsync(mail, caseId, Settings.EspoCrmUrl).ConfigureAwait(true);
mail.Display(false);
}
catch (Exception ex)
{
Logger.Error(ex, "LaunchComposeFromCaseByIdAsync failed for caseId={CaseId}", caseId);
if (mail != null) try { Marshal.ReleaseComObject(mail); } catch { }
MessageBox.Show(
"פתיחת המייל מתיק נכשלה: " + ex.Message,
"Marcus-Law OutlookAddin",
MessageBoxButton.OK,
MessageBoxImage.Error);
}
}
/// <summary>
@@ -419,6 +631,9 @@ namespace OutlookAddin.Services
public void Dispose()
{
try { _outlookApp.ItemSend -= OnItemSend; } catch { }
try { _sentItemsWatcher?.Dispose(); } catch { }
try { _pipeListener?.Dispose(); } catch { }
try { _sidebarController?.Dispose(); } catch { }
try { _retryProcessor?.Dispose(); } catch { }
try { _httpClient?.Dispose(); } catch { }
+162
View File
@@ -0,0 +1,162 @@
using System;
using System.Runtime.InteropServices;
using System.Threading.Tasks;
using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookAddin.Services
{
/// <summary>
/// Drives the "Compose from Case" flow:
/// 1. Caller opens a MailItem (either via Inspector ribbon or by us
/// creating a brand new MailItem via the URL protocol).
/// 2. Resolve full Case details (name, number, contacts) and the primary
/// contact's email address.
/// 3. Populate the MailItem (To/Subject/footer) and stamp two
/// UserProperties: <c>EspoCaseId</c> and <c>OutlookAddinGuid</c>.
/// 4. Display the MailItem so the user can edit/send.
/// </summary>
public sealed class ComposeService
{
private const string EspoCaseIdProperty = "EspoCaseId";
private const string EspoCaseNameProperty = "EspoCaseName";
private const string AddInGuidProperty = "OutlookAddinGuid";
private readonly IEspoCrmClient _client;
private readonly ILogger _logger;
public ComposeService(IEspoCrmClient client, ILogger logger)
{
_client = client ?? throw new ArgumentNullException(nameof(client));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public async Task<ComposeResult> ApplyCaseToMailAsync(
Outlook.MailItem mail,
string caseId,
string baseCrmUrl)
{
if (mail == null) throw new ArgumentNullException(nameof(mail));
if (string.IsNullOrWhiteSpace(caseId)) throw new ArgumentException("caseId required", nameof(caseId));
var caseEntity = await _client.GetCaseAsync(caseId, "id,name,number,status,contactsIds,accountId").ConfigureAwait(true);
ContactEntity? primaryContact = null;
if (caseEntity.ContactsIds != null && caseEntity.ContactsIds.Count > 0)
{
try
{
primaryContact = await _client.GetContactAsync(
caseEntity.ContactsIds[0],
"id,name,emailAddress",
default).ConfigureAwait(true);
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to load primary contact for case {CaseId}", caseId);
}
}
var guid = Guid.NewGuid().ToString("N");
StampUserProperty(mail, EspoCaseIdProperty, caseEntity.Id);
StampUserProperty(mail, EspoCaseNameProperty, caseEntity.Name ?? caseEntity.Number ?? caseEntity.Id);
StampUserProperty(mail, AddInGuidProperty, guid);
try
{
if (!string.IsNullOrWhiteSpace(primaryContact?.EmailAddress))
{
var existingTo = mail.To ?? string.Empty;
if (!existingTo.Contains(primaryContact!.EmailAddress!))
{
mail.To = string.IsNullOrEmpty(existingTo)
? primaryContact.EmailAddress
: existingTo + "; " + primaryContact.EmailAddress;
}
}
var caseLabel = caseEntity.Number ?? caseEntity.Name ?? caseEntity.Id;
var existingSubject = mail.Subject ?? string.Empty;
if (!existingSubject.Contains("[" + caseLabel + "]"))
{
mail.Subject = "[" + caseLabel + "] " + existingSubject;
}
var crmCaseUrl = baseCrmUrl?.TrimEnd('/') + "/#Case/view/" + caseEntity.Id;
var footer =
"<br/><hr/><p style=\"direction:rtl;font-size:11px;color:#666;\">" +
"תיק EspoCRM: " +
"<a href=\"" + crmCaseUrl + "\">" + System.Net.WebUtility.HtmlEncode(caseLabel) + "</a>" +
"</p>";
var html = mail.HTMLBody ?? string.Empty;
if (!html.Contains("#Case/view/" + caseEntity.Id))
{
mail.HTMLBody = html + footer;
}
}
catch (Exception ex)
{
_logger.Warning(ex, "ComposeService: failed to populate MailItem for case {CaseId}", caseId);
}
try { mail.Save(); } catch { /* ignore */ }
return new ComposeResult(
guid,
new FilingTarget("Case", caseEntity.Id, caseEntity.Name ?? caseEntity.Id));
}
public static string? ReadEspoCaseId(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseIdProperty);
public static string? ReadEspoCaseName(Outlook.MailItem mail) => TryReadUserProperty(mail, EspoCaseNameProperty);
public static string? ReadAddInGuid(Outlook.MailItem mail) => TryReadUserProperty(mail, AddInGuidProperty);
private static void StampUserProperty(Outlook.MailItem mail, string name, string value)
{
Outlook.UserProperties? props = null;
Outlook.UserProperty? prop = null;
try
{
props = mail.UserProperties;
prop = props.Find(name) ?? props.Add(name, Outlook.OlUserPropertyType.olText, false);
prop.Value = value;
}
catch { /* ignore */ }
finally
{
if (prop != null && Marshal.IsComObject(prop)) Marshal.ReleaseComObject(prop);
if (props != null && Marshal.IsComObject(props)) Marshal.ReleaseComObject(props);
}
}
private static string? TryReadUserProperty(Outlook.MailItem mail, string name)
{
Outlook.UserProperties? props = null;
Outlook.UserProperty? prop = null;
try
{
props = mail.UserProperties;
prop = props?.Find(name);
return prop?.Value as string;
}
catch { return null; }
finally
{
if (prop != null && Marshal.IsComObject(prop)) Marshal.ReleaseComObject(prop);
if (props != null && Marshal.IsComObject(props)) Marshal.ReleaseComObject(props);
}
}
}
public sealed class ComposeResult
{
public string Guid { get; }
public FilingTarget Target { get; }
public ComposeResult(string guid, FilingTarget target)
{
Guid = guid;
Target = target;
}
}
}
@@ -0,0 +1,87 @@
using System;
using System.IO.Pipes;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Threading;
using Serilog;
namespace OutlookAddin.Services
{
/// <summary>
/// Hosts a named pipe (<c>\\.\pipe\MarcusLaw.OutlookAddin</c>) that the
/// out-of-process URL-protocol handler writes lines to. Each line is
/// dispatched to <see cref="MessageReceived"/> on the WPF UI thread so
/// callers can freely touch the Outlook COM model.
/// </summary>
public sealed class NamedPipeListener : IDisposable
{
public const string PipeName = "MarcusLaw.OutlookAddin";
private readonly CancellationTokenSource _cts = new CancellationTokenSource();
private readonly Dispatcher _dispatcher;
private readonly ILogger _logger;
private Task? _loop;
public event EventHandler<string>? MessageReceived;
public NamedPipeListener(Dispatcher dispatcher, ILogger logger)
{
_dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
}
public void Start()
{
if (_loop != null) return;
_loop = Task.Run(() => RunAsync(_cts.Token));
_logger.Information("NamedPipeListener started ({Pipe})", PipeName);
}
private async Task RunAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
using var server = new NamedPipeServerStream(
PipeName,
PipeDirection.InOut,
maxNumberOfServerInstances: 1,
PipeTransmissionMode.Byte,
PipeOptions.Asynchronous);
await server.WaitForConnectionAsync(ct).ConfigureAwait(false);
using var reader = new System.IO.StreamReader(server, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
string? line;
while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null)
{
var captured = line;
try
{
_dispatcher.BeginInvoke((Action)(() => MessageReceived?.Invoke(this, captured)));
}
catch (Exception ex)
{
_logger.Warning(ex, "NamedPipeListener dispatcher invoke failed");
}
}
}
catch (OperationCanceledException) { return; }
catch (Exception ex)
{
_logger.Warning(ex, "NamedPipeListener loop iteration failed — retrying");
try { await Task.Delay(TimeSpan.FromSeconds(2), ct).ConfigureAwait(false); }
catch (OperationCanceledException) { return; }
}
}
}
public void Dispose()
{
try { _cts.Cancel(); } catch { /* ignore */ }
_cts.Dispose();
}
}
}
@@ -0,0 +1,179 @@
using System;
using System.Collections.Generic;
using System.Runtime.InteropServices;
using System.Threading;
using MarcusLaw.OutlookAddin.Core.Models;
using Serilog;
using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookAddin.Services
{
/// <summary>
/// Watches the user's default Sent Items folder for newly added mail
/// that carries our <c>OutlookAddinGuid</c> UserProperty. When a match
/// arrives, fires <see cref="MailFiled"/> with the resolved
/// <see cref="MailEnvelope"/> + target so the host can POST it to
/// EspoCRM.
///
/// The MAPI provider (Outlook) only fires Items.ItemAdd while the
/// reference stays rooted, so we keep <see cref="_sentItems"/> in a
/// field — letting it be GC'd silently drops the subscription.
/// </summary>
public sealed class SentItemsWatcher : IDisposable
{
private readonly Outlook.Application _app;
private readonly ILogger _logger;
private readonly Dictionary<string, PendingItem> _pending = new Dictionary<string, PendingItem>();
private readonly Timer _expiryTimer;
private Outlook.Items? _sentItems;
private int _disposed;
public TimeSpan PendingTimeout { get; } = TimeSpan.FromMinutes(2);
public event EventHandler<SentMailMatchedEventArgs>? MailMatched;
public SentItemsWatcher(Outlook.Application app, ILogger logger)
{
_app = app ?? throw new ArgumentNullException(nameof(app));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_expiryTimer = new Timer(_ => SweepExpired(), null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));
}
public void Initialize()
{
try
{
var ns = _app.Session;
var folder = ns.GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
_sentItems = folder.Items;
_sentItems.ItemAdd += OnSentItemAdd;
_logger.Information("SentItemsWatcher attached to Sent Items folder");
}
catch (Exception ex)
{
_logger.Error(ex, "SentItemsWatcher.Initialize failed");
}
}
/// <summary>
/// Registers a guid → target mapping. When a Sent Items item with
/// the matching UserProperty arrives, the watcher fires
/// <see cref="MailMatched"/>.
/// </summary>
public void Track(string guid, FilingTarget target)
{
if (string.IsNullOrWhiteSpace(guid)) return;
lock (_pending)
{
_pending[guid] = new PendingItem(target, DateTimeOffset.UtcNow);
}
}
private void OnSentItemAdd(object item)
{
if (Volatile.Read(ref _disposed) != 0) return;
try
{
if (item is not Outlook.MailItem mail) return;
var guid = TryReadUserProperty(mail, "OutlookAddinGuid");
var caseId = TryReadUserProperty(mail, "EspoCaseId");
var caseName = TryReadUserProperty(mail, "EspoCaseName");
if (string.IsNullOrWhiteSpace(guid)) return;
PendingItem? pending = null;
lock (_pending)
{
if (_pending.TryGetValue(guid!, out var p))
{
pending = p;
_pending.Remove(guid!);
}
}
var target = pending?.Target;
if (target == null && !string.IsNullOrWhiteSpace(caseId))
{
target = new FilingTarget("Case", caseId!, caseName ?? caseId!);
}
if (target == null)
{
_logger.Information("SentItemsWatcher: matched guid {Guid} has no target — skipping", guid);
return;
}
MailMatched?.Invoke(this, new SentMailMatchedEventArgs(mail, target));
}
catch (Exception ex)
{
_logger.Warning(ex, "SentItemsWatcher.OnSentItemAdd failed");
}
}
private static string? TryReadUserProperty(Outlook.MailItem mail, string name)
{
Outlook.UserProperties? props = null;
Outlook.UserProperty? prop = null;
try
{
props = mail.UserProperties;
prop = props?.Find(name);
return prop?.Value as string;
}
catch { return null; }
finally
{
if (prop != null && Marshal.IsComObject(prop)) Marshal.ReleaseComObject(prop);
if (props != null && Marshal.IsComObject(props)) Marshal.ReleaseComObject(props);
}
}
private void SweepExpired()
{
if (Volatile.Read(ref _disposed) != 0) return;
var cutoff = DateTimeOffset.UtcNow - PendingTimeout;
lock (_pending)
{
var stale = new List<string>();
foreach (var kv in _pending)
{
if (kv.Value.RegisteredAt < cutoff) stale.Add(kv.Key);
}
foreach (var k in stale) _pending.Remove(k);
}
}
public void Dispose()
{
if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
try { if (_sentItems != null) _sentItems.ItemAdd -= OnSentItemAdd; } catch { /* ignore */ }
_sentItems = null;
try { _expiryTimer.Dispose(); } catch { /* ignore */ }
}
private sealed class PendingItem
{
public FilingTarget Target { get; }
public DateTimeOffset RegisteredAt { get; }
public PendingItem(FilingTarget target, DateTimeOffset at)
{
Target = target;
RegisteredAt = at;
}
}
}
public sealed class SentMailMatchedEventArgs : EventArgs
{
public Outlook.MailItem Mail { get; }
public FilingTarget Target { get; }
public SentMailMatchedEventArgs(Outlook.MailItem mail, FilingTarget target)
{
Mail = mail;
Target = target;
}
}
}
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net48</TargetFramework>
<RootNamespace>OutlookAddinProtocolHandler</RootNamespace>
<AssemblyName>OutlookAddinProtocolHandler</AssemblyName>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<UseWindowsForms>true</UseWindowsForms>
<Company>Marcus-Law</Company>
<Product>OutlookAddinProtocolHandler</Product>
</PropertyGroup>
<ItemGroup>
<Reference Include="System.Web" />
</ItemGroup>
</Project>
@@ -0,0 +1,89 @@
using System;
using System.Collections.Specialized;
using System.IO;
using System.IO.Pipes;
using System.Text;
using System.Web;
using System.Windows.Forms;
namespace OutlookAddinProtocolHandler
{
/// <summary>
/// Tiny shim launched when the user clicks an <c>outlookaddin://</c> URL.
/// Parses the URL and forwards a one-line command over the named pipe
/// owned by the in-process VSTO add-in. If Outlook isn't running, the
/// pipe connect will fail and we surface a MessageBox.
/// </summary>
public static class Program
{
private const string PipeName = "MarcusLaw.OutlookAddin";
[STAThread]
public static int Main(string[] args)
{
try
{
if (args.Length == 0)
{
return Fail("השימוש: outlookaddin://compose?caseId=<id>");
}
var raw = args[0];
if (!raw.StartsWith("outlookaddin://", StringComparison.OrdinalIgnoreCase))
{
return Fail("URL לא תקין: " + raw);
}
var uri = new Uri(raw);
var command = (uri.Host ?? "").ToLowerInvariant();
var query = HttpUtility.ParseQueryString(uri.Query ?? string.Empty);
switch (command)
{
case "compose":
return SendCompose(query);
default:
return Fail("פעולה לא ידועה: " + command);
}
}
catch (Exception ex)
{
return Fail("שגיאה: " + ex.Message);
}
}
private static int SendCompose(NameValueCollection query)
{
var caseId = query["caseId"];
if (string.IsNullOrWhiteSpace(caseId))
{
return Fail("חסר caseId בכתובת ה-URL");
}
var payload = "COMPOSE " + caseId.Trim() + "\n";
try
{
using var pipe = new NamedPipeClientStream(".", PipeName, PipeDirection.InOut);
pipe.Connect(timeout: 3000);
var bytes = Encoding.UTF8.GetBytes(payload);
pipe.Write(bytes, 0, bytes.Length);
pipe.Flush();
return 0;
}
catch (TimeoutException)
{
return Fail("Outlook אינו פתוח, או שהתוסף עדיין לא נטען.");
}
catch (IOException ex)
{
return Fail("תקלת תקשורת עם התוסף: " + ex.Message);
}
}
private static int Fail(string message)
{
MessageBox.Show(message, "Marcus-Law OutlookAddin", MessageBoxButtons.OK, MessageBoxIcon.Warning);
return 1;
}
}
}
+41
View File
@@ -0,0 +1,41 @@
# Registers the `outlookaddin://` URL protocol in HKCU so that
# clicking a link like `outlookaddin://compose?caseId=…` launches the
# OutlookAddinProtocolHandler.exe shim, which forwards the request over
# a named pipe to the running VSTO add-in.
#
# Usage:
# .\install-protocol.ps1 -ExePath "C:\Path\To\OutlookAddinProtocolHandler.exe"
#
# Or, to derive the path automatically from a ClickOnce install:
# .\install-protocol.ps1
[CmdletBinding()]
param(
[string]$ExePath
)
$ErrorActionPreference = 'Stop'
if (-not $ExePath) {
$candidate = Join-Path $env:LOCALAPPDATA "Apps\OutlookAddinProtocolHandler\OutlookAddinProtocolHandler.exe"
if (Test-Path $candidate) {
$ExePath = $candidate
} else {
throw "ExePath not supplied and default location not found: $candidate"
}
}
if (-not (Test-Path $ExePath)) {
throw "Protocol handler not found: $ExePath"
}
$root = "HKCU:\Software\Classes\outlookaddin"
New-Item -Path $root -Force | Out-Null
Set-ItemProperty -Path $root -Name "(default)" -Value "URL:OutlookAddin Protocol"
Set-ItemProperty -Path $root -Name "URL Protocol" -Value ""
$shellCmd = Join-Path $root "shell\open\command"
New-Item -Path $shellCmd -Force | Out-Null
Set-ItemProperty -Path $shellCmd -Name "(default)" -Value ("`"" + $ExePath + "`" `"%1`"")
Write-Host "Registered outlookaddin:// -> $ExePath" -ForegroundColor Green
+3
View File
@@ -0,0 +1,3 @@
$ErrorActionPreference = 'SilentlyContinue'
Remove-Item -Path "HKCU:\Software\Classes\outlookaddin" -Recurse -Force
Write-Host "Removed outlookaddin:// protocol registration" -ForegroundColor Yellow