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
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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