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:
@@ -10,6 +10,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin.Tests", "src\O
|
|||||||
EndProject
|
EndProject
|
||||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin", "src\OutlookAddin\OutlookAddin.csproj", "{7A8B6509-CB21-42BD-A42C-2735170AFFD2}"
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddin", "src\OutlookAddin\OutlookAddin.csproj", "{7A8B6509-CB21-42BD-A42C-2735170AFFD2}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "OutlookAddinProtocolHandler", "src\OutlookAddinProtocolHandler\OutlookAddinProtocolHandler.csproj", "{A0000000-0000-0000-0000-000000000005}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
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}.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.ActiveCfg = Release|Any CPU
|
||||||
{7A8B6509-CB21-42BD-A42C-2735170AFFD2}.Release|Any CPU.Build.0 = 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
|
EndGlobalSection
|
||||||
GlobalSection(SolutionProperties) = preSolution
|
GlobalSection(SolutionProperties) = preSolution
|
||||||
HideSolutionNode = FALSE
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -232,18 +232,30 @@
|
|||||||
<EmbeddedResource Include="Ribbon\ExplorerRibbon.xml">
|
<EmbeddedResource Include="Ribbon\ExplorerRibbon.xml">
|
||||||
<LogicalName>OutlookAddin.Ribbon.ExplorerRibbon.xml</LogicalName>
|
<LogicalName>OutlookAddin.Ribbon.ExplorerRibbon.xml</LogicalName>
|
||||||
</EmbeddedResource>
|
</EmbeddedResource>
|
||||||
|
<EmbeddedResource Include="Ribbon\InspectorRibbon.xml">
|
||||||
|
<LogicalName>OutlookAddin.Ribbon.InspectorRibbon.xml</LogicalName>
|
||||||
|
</EmbeddedResource>
|
||||||
<Compile Include="Services\AddInHost.cs">
|
<Compile Include="Services\AddInHost.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="Services\ComposeService.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
<Compile Include="Services\ExplorerSelectionHandler.cs">
|
<Compile Include="Services\ExplorerSelectionHandler.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
<Compile Include="Services\MailItemExtractor.cs">
|
<Compile Include="Services\MailItemExtractor.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="Services\NamedPipeListener.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
<Compile Include="Services\RetryQueueProcessor.cs">
|
<Compile Include="Services\RetryQueueProcessor.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
<Compile Include="Services\SentItemsWatcher.cs">
|
||||||
|
<SubType>Code</SubType>
|
||||||
|
</Compile>
|
||||||
<Compile Include="Services\SidebarController.cs">
|
<Compile Include="Services\SidebarController.cs">
|
||||||
<SubType>Code</SubType>
|
<SubType>Code</SubType>
|
||||||
</Compile>
|
</Compile>
|
||||||
|
|||||||
@@ -7,36 +7,44 @@ using Outlook = Microsoft.Office.Interop.Outlook;
|
|||||||
|
|
||||||
namespace OutlookAddin.Ribbon
|
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)]
|
[ComVisible(true)]
|
||||||
public class ExplorerRibbon : IRibbonExtensibility
|
public class ExplorerRibbon : IRibbonExtensibility
|
||||||
{
|
{
|
||||||
private const string ResourceName = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
private const string ExplorerResource = "OutlookAddin.Ribbon.ExplorerRibbon.xml";
|
||||||
private const string ButtonId = "FileToEspoCrm";
|
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)
|
public string GetCustomUI(string ribbonID)
|
||||||
{
|
{
|
||||||
if (ribbonID != "Microsoft.Outlook.Explorer") return string.Empty;
|
switch (ribbonID)
|
||||||
using (var stream = Assembly.GetExecutingAssembly().GetManifestResourceStream(ResourceName))
|
|
||||||
{
|
{
|
||||||
if (stream == null) return string.Empty;
|
case "Microsoft.Outlook.Explorer":
|
||||||
using (var reader = new StreamReader(stream))
|
return LoadResource(ExplorerResource);
|
||||||
{
|
case "Microsoft.Outlook.Mail.Compose":
|
||||||
return reader.ReadToEnd();
|
return LoadResource(InspectorResource);
|
||||||
}
|
default:
|
||||||
|
return string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void OnRibbonLoad(IRibbonUI ribbon)
|
public void OnRibbonLoad(IRibbonUI ribbon)
|
||||||
{
|
{
|
||||||
_ribbon = ribbon;
|
_explorerRibbon = ribbon;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
|
var explorer = Globals.ThisAddIn.Application.ActiveExplorer();
|
||||||
if (explorer != null)
|
if (explorer != null)
|
||||||
{
|
{
|
||||||
explorer.SelectionChange += () => _ribbon?.InvalidateControl(ButtonId);
|
explorer.SelectionChange += () => _explorerRibbon?.InvalidateControl(FileButtonId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
@@ -45,6 +53,11 @@ namespace OutlookAddin.Ribbon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnInspectorRibbonLoad(IRibbonUI ribbon)
|
||||||
|
{
|
||||||
|
_inspectorRibbon = ribbon;
|
||||||
|
}
|
||||||
|
|
||||||
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
|
public bool OnGetFileToEspoCrmEnabled(IRibbonControl control)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -72,5 +85,48 @@ namespace OutlookAddin.Ribbon
|
|||||||
{
|
{
|
||||||
Globals.ThisAddIn.AddInHost.LaunchSettings();
|
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>
|
||||||
@@ -37,12 +37,15 @@ namespace OutlookAddin.Services
|
|||||||
public IEspoCrmClient? CrmClient { get; private set; }
|
public IEspoCrmClient? CrmClient { get; private set; }
|
||||||
public IFilingService? FilingService { get; private set; }
|
public IFilingService? FilingService { get; private set; }
|
||||||
public IMatchingService? MatchingService { get; private set; }
|
public IMatchingService? MatchingService { get; private set; }
|
||||||
|
public ComposeService? ComposeService { get; private set; }
|
||||||
|
|
||||||
private readonly Outlook.Application _outlookApp;
|
private readonly Outlook.Application _outlookApp;
|
||||||
private readonly MemoryCache _matchCache = new MemoryCache(new MemoryCacheOptions());
|
private readonly MemoryCache _matchCache = new MemoryCache(new MemoryCacheOptions());
|
||||||
private RetryQueueProcessor? _retryProcessor;
|
private RetryQueueProcessor? _retryProcessor;
|
||||||
private HttpClient? _httpClient;
|
private HttpClient? _httpClient;
|
||||||
private SidebarController? _sidebarController;
|
private SidebarController? _sidebarController;
|
||||||
|
private SentItemsWatcher? _sentItemsWatcher;
|
||||||
|
private NamedPipeListener? _pipeListener;
|
||||||
|
|
||||||
public AddInHost(Outlook.Application outlookApp)
|
public AddInHost(Outlook.Application outlookApp)
|
||||||
{
|
{
|
||||||
@@ -56,6 +59,139 @@ namespace OutlookAddin.Services
|
|||||||
RetryQueue = new DiskRetryQueue(Logger);
|
RetryQueue = new DiskRetryQueue(Logger);
|
||||||
|
|
||||||
TryInitializeCrm();
|
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>
|
/// <summary>
|
||||||
@@ -137,6 +273,7 @@ namespace OutlookAddin.Services
|
|||||||
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
|
CrmClient = new EspoCrmClient(_httpClient, baseUrl.ToString(), creds.Username, creds.ApiKey, Logger);
|
||||||
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
|
||||||
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
|
||||||
|
ComposeService = new ComposeService(CrmClient, Logger);
|
||||||
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
|
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
|
||||||
_retryProcessor.Start();
|
_retryProcessor.Start();
|
||||||
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
|
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
|
||||||
@@ -159,6 +296,81 @@ namespace OutlookAddin.Services
|
|||||||
CrmClient = null;
|
CrmClient = null;
|
||||||
FilingService = null;
|
FilingService = null;
|
||||||
MatchingService = 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>
|
/// <summary>
|
||||||
@@ -419,6 +631,9 @@ namespace OutlookAddin.Services
|
|||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
|
try { _outlookApp.ItemSend -= OnItemSend; } catch { }
|
||||||
|
try { _sentItemsWatcher?.Dispose(); } catch { }
|
||||||
|
try { _pipeListener?.Dispose(); } catch { }
|
||||||
try { _sidebarController?.Dispose(); } catch { }
|
try { _sidebarController?.Dispose(); } catch { }
|
||||||
try { _retryProcessor?.Dispose(); } catch { }
|
try { _retryProcessor?.Dispose(); } catch { }
|
||||||
try { _httpClient?.Dispose(); } catch { }
|
try { _httpClient?.Dispose(); } catch { }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
$ErrorActionPreference = 'SilentlyContinue'
|
||||||
|
Remove-Item -Path "HKCU:\Software\Classes\outlookaddin" -Recurse -Force
|
||||||
|
Write-Host "Removed outlookaddin:// protocol registration" -ForegroundColor Yellow
|
||||||
Reference in New Issue
Block a user