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 =
+ "
" +
+ "תיק EspoCRM: " +
+ "" + System.Net.WebUtility.HtmlEncode(caseLabel) + "" +
+ "
";
+ 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;
+ }
+ }
+}
diff --git a/src/OutlookAddin/Services/NamedPipeListener.cs b/src/OutlookAddin/Services/NamedPipeListener.cs
new file mode 100644
index 0000000..34d48aa
--- /dev/null
+++ b/src/OutlookAddin/Services/NamedPipeListener.cs
@@ -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
+{
+ ///
+ /// Hosts a named pipe (\\.\pipe\MarcusLaw.OutlookAddin) that the
+ /// out-of-process URL-protocol handler writes lines to. Each line is
+ /// dispatched to on the WPF UI thread so
+ /// callers can freely touch the Outlook COM model.
+ ///
+ 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? 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();
+ }
+ }
+}
diff --git a/src/OutlookAddin/Services/SentItemsWatcher.cs b/src/OutlookAddin/Services/SentItemsWatcher.cs
new file mode 100644
index 0000000..c21f101
--- /dev/null
+++ b/src/OutlookAddin/Services/SentItemsWatcher.cs
@@ -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
+{
+ ///
+ /// Watches the user's default Sent Items folder for newly added mail
+ /// that carries our OutlookAddinGuid UserProperty. When a match
+ /// arrives, fires with the resolved
+ /// + 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 in a
+ /// field — letting it be GC'd silently drops the subscription.
+ ///
+ public sealed class SentItemsWatcher : IDisposable
+ {
+ private readonly Outlook.Application _app;
+ private readonly ILogger _logger;
+ private readonly Dictionary _pending = new Dictionary();
+ private readonly Timer _expiryTimer;
+
+ private Outlook.Items? _sentItems;
+ private int _disposed;
+
+ public TimeSpan PendingTimeout { get; } = TimeSpan.FromMinutes(2);
+
+ public event EventHandler? 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");
+ }
+ }
+
+ ///
+ /// Registers a guid → target mapping. When a Sent Items item with
+ /// the matching UserProperty arrives, the watcher fires
+ /// .
+ ///
+ 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();
+ 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;
+ }
+ }
+}
diff --git a/src/OutlookAddinProtocolHandler/OutlookAddinProtocolHandler.csproj b/src/OutlookAddinProtocolHandler/OutlookAddinProtocolHandler.csproj
new file mode 100644
index 0000000..38e25c3
--- /dev/null
+++ b/src/OutlookAddinProtocolHandler/OutlookAddinProtocolHandler.csproj
@@ -0,0 +1,17 @@
+
+
+ WinExe
+ net48
+ OutlookAddinProtocolHandler
+ OutlookAddinProtocolHandler
+ latest
+ enable
+ true
+ Marcus-Law
+ OutlookAddinProtocolHandler
+
+
+
+
+
+
diff --git a/src/OutlookAddinProtocolHandler/Program.cs b/src/OutlookAddinProtocolHandler/Program.cs
new file mode 100644
index 0000000..5376683
--- /dev/null
+++ b/src/OutlookAddinProtocolHandler/Program.cs
@@ -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
+{
+ ///
+ /// Tiny shim launched when the user clicks an outlookaddin:// 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.
+ ///
+ 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=");
+ }
+
+ 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;
+ }
+ }
+}
diff --git a/tools/install-protocol.ps1 b/tools/install-protocol.ps1
new file mode 100644
index 0000000..ce2af01
--- /dev/null
+++ b/tools/install-protocol.ps1
@@ -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
diff --git a/tools/uninstall-protocol.ps1 b/tools/uninstall-protocol.ps1
new file mode 100644
index 0000000..b7d6c89
--- /dev/null
+++ b/tools/uninstall-protocol.ps1
@@ -0,0 +1,3 @@
+$ErrorActionPreference = 'SilentlyContinue'
+Remove-Item -Path "HKCU:\Software\Classes\outlookaddin" -Recurse -Force
+Write-Host "Removed outlookaddin:// protocol registration" -ForegroundColor Yellow