diff --git a/src/OutlookAddin.Core/Models/FolderTreeNode.cs b/src/OutlookAddin.Core/Models/FolderTreeNode.cs
new file mode 100644
index 0000000..76112f5
--- /dev/null
+++ b/src/OutlookAddin.Core/Models/FolderTreeNode.cs
@@ -0,0 +1,24 @@
+using System.Collections.Generic;
+
+namespace MarcusLaw.OutlookAddin.Core.Models
+{
+ ///
+ /// Outlook-independent representation of a mail folder, used to pass
+ /// the user's folder hierarchy from the host project (which can touch
+ /// Outlook COM) into the UI project (which cannot).
+ ///
+ public sealed class FolderTreeNode
+ {
+ public string StoreId { get; set; } = string.Empty;
+
+ public string FolderPath { get; set; } = string.Empty;
+
+ public string DisplayName { get; set; } = string.Empty;
+
+ public string StoreDisplayName { get; set; } = string.Empty;
+
+ public int Depth { get; set; }
+
+ public List Children { get; set; } = new List();
+ }
+}
diff --git a/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml
index 6fc1d34..e0aa5cb 100644
--- a/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml
+++ b/src/OutlookAddin.UI/Dialogs/SettingsDialog.xaml
@@ -63,6 +63,39 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/OutlookAddin.UI/ViewModels/FolderRowViewModel.cs b/src/OutlookAddin.UI/ViewModels/FolderRowViewModel.cs
new file mode 100644
index 0000000..bf06b01
--- /dev/null
+++ b/src/OutlookAddin.UI/ViewModels/FolderRowViewModel.cs
@@ -0,0 +1,42 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using MarcusLaw.OutlookAddin.Core.Configuration;
+using MarcusLaw.OutlookAddin.Core.Models;
+
+namespace MarcusLaw.OutlookAddin.UI.ViewModels
+{
+ ///
+ /// One row in the Folders tab of the Settings dialog. Wraps a
+ /// with three observable fields the user
+ /// can toggle: IsWatched, Mode, ConfidenceThreshold.
+ ///
+ public sealed partial class FolderRowViewModel : ObservableObject
+ {
+ public FolderTreeNode Node { get; }
+
+ [ObservableProperty]
+ private bool isWatched;
+
+ [ObservableProperty]
+ private WatchMode mode = WatchMode.NotifyOnly;
+
+ [ObservableProperty]
+ private double confidenceThreshold = 0.8;
+
+ public string DisplayLabel => new string(' ', Node.Depth * 2) + Node.DisplayName;
+
+ public string FullPath => Node.StoreDisplayName + Node.FolderPath;
+
+ public FolderRowViewModel(FolderTreeNode node)
+ {
+ Node = node;
+ }
+
+ public WatchedFolder ToWatchedFolder() => new WatchedFolder
+ {
+ StoreId = Node.StoreId,
+ FolderPath = Node.FolderPath,
+ Mode = Mode,
+ ConfidenceThreshold = ConfidenceThreshold
+ };
+ }
+}
diff --git a/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
index 4821112..5d105ec 100644
--- a/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
+++ b/src/OutlookAddin.UI/ViewModels/SettingsViewModel.cs
@@ -1,10 +1,13 @@
using System;
+using System.Collections.Generic;
+using System.Collections.ObjectModel;
using System.Net.Http;
using System.Threading.Tasks;
using System.Windows.Media;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using MarcusLaw.OutlookAddin.Core.Configuration;
+using MarcusLaw.OutlookAddin.Core.Models;
using MarcusLaw.OutlookAddin.Core.Security;
using MarcusLaw.OutlookAddin.Core.Services;
using Serilog;
@@ -47,6 +50,8 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
public event EventHandler? RequestClose;
+ public ObservableCollection Folders { get; } = new ObservableCollection();
+
public SettingsViewModel(
AppSettings settings,
SettingsManager settingsManager,
@@ -69,6 +74,37 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
VersionLine = $"גרסה {version}";
}
+ ///
+ /// Populates the Folders tab. The host project owns the Outlook
+ /// session and must build the flat tree on the STA thread before
+ /// handing it here.
+ ///
+ public void LoadFolderTree(IReadOnlyList tree)
+ {
+ Folders.Clear();
+ if (tree == null) return;
+
+ var watchedLookup = new Dictionary(StringComparer.Ordinal);
+ foreach (var w in _settings.WatchedFolders)
+ {
+ watchedLookup[WatchedKey(w.StoreId, w.FolderPath)] = w;
+ }
+
+ foreach (var node in tree)
+ {
+ var row = new FolderRowViewModel(node);
+ if (watchedLookup.TryGetValue(WatchedKey(node.StoreId, node.FolderPath), out var existing))
+ {
+ row.IsWatched = true;
+ row.Mode = existing.Mode;
+ row.ConfidenceThreshold = existing.ConfidenceThreshold;
+ }
+ Folders.Add(row);
+ }
+ }
+
+ private static string WatchedKey(string storeId, string path) => storeId + "|" + path;
+
[RelayCommand]
private async Task TestConnectionAsync()
{
@@ -130,10 +166,18 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
_settings.EspoCrmUrl = EspoCrmUrl.TrimEnd('/');
_settings.Locale = string.IsNullOrWhiteSpace(Locale) ? "he-IL" : Locale;
+
+ _settings.WatchedFolders.Clear();
+ foreach (var row in Folders)
+ {
+ if (row.IsWatched) _settings.WatchedFolders.Add(row.ToWatchedFolder());
+ }
+
_settingsManager.Save(_settings);
_credentialStore.Save(Username.Trim(), apiKey);
- _logger.Information("Settings saved (user={User}, url={Url})", Username, _settings.EspoCrmUrl);
+ _logger.Information("Settings saved (user={User}, url={Url}, watched={WatchedCount})",
+ Username, _settings.EspoCrmUrl, _settings.WatchedFolders.Count);
DialogResult = true;
RequestClose?.Invoke(this, EventArgs.Empty);
diff --git a/src/OutlookAddin/OutlookAddin.csproj b/src/OutlookAddin/OutlookAddin.csproj
index 9f7c993..5908df4 100644
--- a/src/OutlookAddin/OutlookAddin.csproj
+++ b/src/OutlookAddin/OutlookAddin.csproj
@@ -244,6 +244,15 @@
Code
+
+ Code
+
+
+ Code
+
+
+ Code
+
Code
diff --git a/src/OutlookAddin/Services/AddInHost.cs b/src/OutlookAddin/Services/AddInHost.cs
index 349f1fe..04968d8 100644
--- a/src/OutlookAddin/Services/AddInHost.cs
+++ b/src/OutlookAddin/Services/AddInHost.cs
@@ -46,6 +46,8 @@ namespace OutlookAddin.Services
private SidebarController? _sidebarController;
private SentItemsWatcher? _sentItemsWatcher;
private NamedPipeListener? _pipeListener;
+ private FolderResolver? _folderResolver;
+ private FolderWatcher? _folderWatcher;
public AddInHost(Outlook.Application outlookApp)
{
@@ -274,6 +276,15 @@ namespace OutlookAddin.Services
FilingService = new FilingService(CrmClient, RetryQueue, Logger);
MatchingService = new MatchingService(CrmClient, _matchCache, Logger);
ComposeService = new ComposeService(CrmClient, Logger);
+ _folderResolver ??= new FolderResolver(_outlookApp, Logger);
+ _folderWatcher = new FolderWatcher(
+ _outlookApp,
+ _folderResolver,
+ MatchingService,
+ FilingService,
+ System.Windows.Threading.Dispatcher.CurrentDispatcher,
+ Logger);
+ _folderWatcher.Apply(Settings.WatchedFolders);
_retryProcessor = new RetryQueueProcessor(RetryQueue, CrmClient, Logger);
_retryProcessor.Start();
Logger.Information("AddInHost: CRM client initialized for {BaseUrl}", baseUrl);
@@ -289,6 +300,8 @@ namespace OutlookAddin.Services
private void DisposeCrmStack()
{
+ try { _folderWatcher?.Dispose(); } catch { /* ignore */ }
+ _folderWatcher = null;
try { _retryProcessor?.Dispose(); } catch { /* ignore */ }
_retryProcessor = null;
try { _httpClient?.Dispose(); } catch { /* ignore */ }
@@ -380,6 +393,15 @@ namespace OutlookAddin.Services
public bool LaunchSettings()
{
var vm = new SettingsViewModel(Settings, SettingsManager, CredentialStore, Logger);
+ try
+ {
+ var tree = FolderTreeBuilder.BuildFlatList(_outlookApp, Logger);
+ vm.LoadFolderTree(tree);
+ }
+ catch (Exception ex)
+ {
+ Logger.Warning(ex, "LaunchSettings: folder tree load failed");
+ }
var dialog = new SettingsDialog(vm);
var ok = dialog.ShowDialog();
if (ok != true) return false;
@@ -388,6 +410,8 @@ namespace OutlookAddin.Services
var refreshed = SettingsManager.Load();
Settings.EspoCrmUrl = refreshed.EspoCrmUrl;
Settings.Locale = refreshed.Locale;
+ Settings.WatchedFolders.Clear();
+ Settings.WatchedFolders.AddRange(refreshed.WatchedFolders);
TryInitializeCrm();
return true;
}
diff --git a/src/OutlookAddin/Services/FolderResolver.cs b/src/OutlookAddin/Services/FolderResolver.cs
new file mode 100644
index 0000000..b71619e
--- /dev/null
+++ b/src/OutlookAddin/Services/FolderResolver.cs
@@ -0,0 +1,151 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using Serilog;
+using Outlook = Microsoft.Office.Interop.Outlook;
+
+namespace OutlookAddin.Services
+{
+ public sealed class FolderResolver
+ {
+ private readonly Outlook.Application _app;
+ private readonly ILogger _logger;
+
+ public FolderResolver(Outlook.Application app, ILogger logger)
+ {
+ _app = app ?? throw new ArgumentNullException(nameof(app));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ ///
+ /// Resolves a stored " + "
+ /// pair back to a live . Path format is
+ /// \\Inbox\\Clients (backslash-separated, starting with the
+ /// store root folder). Caller owns the returned RCW and must release it.
+ ///
+ public Outlook.Folder? Resolve(string storeId, string folderPath)
+ {
+ if (string.IsNullOrWhiteSpace(storeId) || string.IsNullOrWhiteSpace(folderPath)) return null;
+
+ Outlook.NameSpace? ns = null;
+ Outlook.Stores? stores = null;
+ Outlook.Store? store = null;
+ Outlook.Folder? root = null;
+ try
+ {
+ ns = _app.Session;
+ stores = ns.Stores;
+ for (int i = 1; i <= stores.Count; i++)
+ {
+ var candidate = stores[i];
+ try
+ {
+ if (string.Equals(candidate.StoreID, storeId, StringComparison.Ordinal))
+ {
+ store = candidate;
+ break;
+ }
+ }
+ finally
+ {
+ if (store != candidate && Marshal.IsComObject(candidate))
+ {
+ Marshal.ReleaseComObject(candidate);
+ }
+ }
+ }
+ if (store == null) return null;
+
+ root = (Outlook.Folder)store.GetRootFolder();
+ var segments = SplitPath(folderPath);
+ return Descend(root, segments, 0);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "FolderResolver.Resolve({StoreId}, {FolderPath}) failed", storeId, folderPath);
+ return null;
+ }
+ finally
+ {
+ if (store != null && Marshal.IsComObject(store)) Marshal.ReleaseComObject(store);
+ if (stores != null && Marshal.IsComObject(stores)) Marshal.ReleaseComObject(stores);
+ if (ns != null && Marshal.IsComObject(ns)) Marshal.ReleaseComObject(ns);
+ // Caller owns root only if it IS the returned folder (segments was empty).
+ }
+ }
+
+ private Outlook.Folder? Descend(Outlook.Folder current, IReadOnlyList segments, int index)
+ {
+ if (current == null) return null;
+ if (index >= segments.Count) return current;
+
+ Outlook.Folders? children = null;
+ try
+ {
+ children = current.Folders;
+ for (int i = 1; i <= children.Count; i++)
+ {
+ var child = (Outlook.Folder)children[i];
+ try
+ {
+ if (string.Equals(child.Name, segments[index], StringComparison.Ordinal))
+ {
+ // Move ownership down — release the current folder above us, return descended.
+ var descended = Descend(child, segments, index + 1);
+ if (descended != child && Marshal.IsComObject(child)) Marshal.ReleaseComObject(child);
+ if (current != null && index > 0 && Marshal.IsComObject(current)) Marshal.ReleaseComObject(current);
+ return descended;
+ }
+ }
+ catch
+ {
+ if (Marshal.IsComObject(child)) Marshal.ReleaseComObject(child);
+ throw;
+ }
+ if (Marshal.IsComObject(child)) Marshal.ReleaseComObject(child);
+ }
+ return null;
+ }
+ finally
+ {
+ if (children != null && Marshal.IsComObject(children)) Marshal.ReleaseComObject(children);
+ }
+ }
+
+ public static string BuildPath(Outlook.Folder folder)
+ {
+ // Outlook gives us "\\Top of Outlook data file\Inbox\Clients" via
+ // Folder.FolderPath; the store root prefix matches Store.DisplayName
+ // and varies per provider — peel it off to get a portable path.
+ if (folder == null) return string.Empty;
+ try
+ {
+ var raw = folder.FolderPath ?? string.Empty;
+ // Strip the leading backslashes and the first segment (store name).
+ if (raw.StartsWith("\\\\", StringComparison.Ordinal))
+ {
+ raw = raw.Substring(2);
+ }
+ var firstSlash = raw.IndexOf('\\');
+ if (firstSlash >= 0)
+ {
+ raw = raw.Substring(firstSlash);
+ }
+ if (string.IsNullOrEmpty(raw)) return "\\";
+ if (!raw.StartsWith("\\", StringComparison.Ordinal)) raw = "\\" + raw;
+ return raw;
+ }
+ catch
+ {
+ return string.Empty;
+ }
+ }
+
+ private static IReadOnlyList SplitPath(string folderPath)
+ {
+ var trimmed = folderPath.TrimStart('\\');
+ if (string.IsNullOrEmpty(trimmed)) return Array.Empty();
+ return trimmed.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
+ }
+ }
+}
diff --git a/src/OutlookAddin/Services/FolderTreeBuilder.cs b/src/OutlookAddin/Services/FolderTreeBuilder.cs
new file mode 100644
index 0000000..e397c1c
--- /dev/null
+++ b/src/OutlookAddin/Services/FolderTreeBuilder.cs
@@ -0,0 +1,120 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using MarcusLaw.OutlookAddin.Core.Models;
+using Serilog;
+using Outlook = Microsoft.Office.Interop.Outlook;
+
+namespace OutlookAddin.Services
+{
+ ///
+ /// One-shot, STA-bound traversal of the user's mailbox tree. Emits a
+ /// flat list of values that the Settings
+ /// dialog can data-bind without keeping any Outlook COM references alive.
+ ///
+ public static class FolderTreeBuilder
+ {
+ private const int MaxDepth = 4;
+ private const int MaxFolders = 500;
+
+ public static IReadOnlyList BuildFlatList(Outlook.Application app, ILogger logger)
+ {
+ var result = new List();
+ Outlook.NameSpace? ns = null;
+ Outlook.Stores? stores = null;
+ try
+ {
+ ns = app.Session;
+ stores = ns.Stores;
+ for (int i = 1; i <= stores.Count; i++)
+ {
+ var store = stores[i];
+ try
+ {
+ var storeId = store.StoreID;
+ var storeName = store.DisplayName ?? "(default)";
+ Outlook.Folder? root = null;
+ try
+ {
+ root = (Outlook.Folder)store.GetRootFolder();
+ VisitChildren(root, storeId, storeName, "", 0, result, logger);
+ }
+ finally
+ {
+ if (root != null && Marshal.IsComObject(root)) Marshal.ReleaseComObject(root);
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Warning(ex, "FolderTreeBuilder: skipping store at index {Index}", i);
+ }
+ finally
+ {
+ if (Marshal.IsComObject(store)) Marshal.ReleaseComObject(store);
+ }
+ if (result.Count >= MaxFolders) break;
+ }
+ }
+ catch (Exception ex)
+ {
+ logger.Warning(ex, "FolderTreeBuilder.BuildFlatList failed");
+ }
+ finally
+ {
+ if (stores != null && Marshal.IsComObject(stores)) Marshal.ReleaseComObject(stores);
+ if (ns != null && Marshal.IsComObject(ns)) Marshal.ReleaseComObject(ns);
+ }
+ return result;
+ }
+
+ private static void VisitChildren(
+ Outlook.Folder parent,
+ string storeId,
+ string storeName,
+ string parentPath,
+ int depth,
+ List sink,
+ ILogger logger)
+ {
+ if (depth >= MaxDepth || sink.Count >= MaxFolders) return;
+
+ Outlook.Folders? children = null;
+ try
+ {
+ children = parent.Folders;
+ for (int i = 1; i <= children.Count; i++)
+ {
+ var child = (Outlook.Folder)children[i];
+ try
+ {
+ var name = child.Name ?? string.Empty;
+ if (string.IsNullOrWhiteSpace(name)) continue;
+ var path = parentPath + "\\" + name;
+ sink.Add(new FolderTreeNode
+ {
+ StoreId = storeId,
+ StoreDisplayName = storeName,
+ FolderPath = path,
+ DisplayName = name,
+ Depth = depth
+ });
+ VisitChildren(child, storeId, storeName, path, depth + 1, sink, logger);
+ }
+ catch (Exception ex)
+ {
+ logger.Warning(ex, "FolderTreeBuilder: skipping a folder at depth {Depth}", depth);
+ }
+ finally
+ {
+ if (Marshal.IsComObject(child)) Marshal.ReleaseComObject(child);
+ }
+ if (sink.Count >= MaxFolders) break;
+ }
+ }
+ finally
+ {
+ if (children != null && Marshal.IsComObject(children)) Marshal.ReleaseComObject(children);
+ }
+ }
+ }
+}
diff --git a/src/OutlookAddin/Services/FolderWatcher.cs b/src/OutlookAddin/Services/FolderWatcher.cs
new file mode 100644
index 0000000..4ecdf0d
--- /dev/null
+++ b/src/OutlookAddin/Services/FolderWatcher.cs
@@ -0,0 +1,262 @@
+using System;
+using System.Collections.Generic;
+using System.Runtime.InteropServices;
+using System.Threading;
+using System.Threading.Tasks;
+using System.Windows.Threading;
+using MarcusLaw.OutlookAddin.Core.Configuration;
+using MarcusLaw.OutlookAddin.Core.Models;
+using MarcusLaw.OutlookAddin.Core.Services;
+using Serilog;
+using Outlook = Microsoft.Office.Interop.Outlook;
+
+namespace OutlookAddin.Services
+{
+ ///
+ /// Subscribes to on each watched
+ /// folder. For each newly arrived mail:
+ /// 1. Dedup via a HashSet of EntryIDs (Outlook fires ItemAdd multiple
+ /// times per arrival under some Exchange flavours).
+ /// 2. Look up the sender via .
+ /// 3. If exactly one unambiguous match and the folder is in
+ /// , file via .
+ /// 4. Otherwise stamp the "Needs filing" Outlook category.
+ ///
+ /// CRITICAL: the Items collections MUST stay rooted, otherwise
+ /// the GC will quietly drop the COM subscription. We hold them in
+ /// _subscriptions for the lifetime of the watcher.
+ ///
+ public sealed class FolderWatcher : IDisposable
+ {
+ private const string NeedsFilingCategory = "Needs filing";
+
+ private readonly Outlook.Application _app;
+ private readonly FolderResolver _resolver;
+ private readonly IMatchingService _matching;
+ private readonly IFilingService _filing;
+ private readonly Dispatcher _dispatcher;
+ private readonly ILogger _logger;
+
+ private readonly List _subscriptions = new List();
+ private readonly HashSet _processedEntryIds = new HashSet(StringComparer.Ordinal);
+ private readonly object _processedLock = new object();
+ private int _disposed;
+
+ public FolderWatcher(
+ Outlook.Application app,
+ FolderResolver resolver,
+ IMatchingService matching,
+ IFilingService filing,
+ Dispatcher dispatcher,
+ ILogger logger)
+ {
+ _app = app ?? throw new ArgumentNullException(nameof(app));
+ _resolver = resolver ?? throw new ArgumentNullException(nameof(resolver));
+ _matching = matching ?? throw new ArgumentNullException(nameof(matching));
+ _filing = filing ?? throw new ArgumentNullException(nameof(filing));
+ _dispatcher = dispatcher ?? throw new ArgumentNullException(nameof(dispatcher));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public void Apply(IReadOnlyList watched)
+ {
+ if (Volatile.Read(ref _disposed) != 0) return;
+ TearDown();
+
+ if (watched == null || watched.Count == 0)
+ {
+ _logger.Information("FolderWatcher: no watched folders configured");
+ return;
+ }
+
+ foreach (var spec in watched)
+ {
+ AttachOne(spec);
+ }
+ }
+
+ private void AttachOne(WatchedFolder spec)
+ {
+ try
+ {
+ var folder = _resolver.Resolve(spec.StoreId, spec.FolderPath);
+ if (folder == null)
+ {
+ _logger.Warning("FolderWatcher: could not resolve {StoreId} / {Path}", spec.StoreId, spec.FolderPath);
+ return;
+ }
+
+ var items = folder.Items;
+ var subscription = new Subscription(spec, folder, items);
+ subscription.Handler = item => OnItemAdd(item, subscription);
+ items.ItemAdd += subscription.Handler;
+ _subscriptions.Add(subscription);
+ _logger.Information("FolderWatcher: attached {Path} (mode={Mode}, threshold={Threshold})",
+ spec.FolderPath, spec.Mode, spec.ConfidenceThreshold);
+ }
+ catch (Exception ex)
+ {
+ _logger.Error(ex, "FolderWatcher.AttachOne failed for {Path}", spec.FolderPath);
+ }
+ }
+
+ private void OnItemAdd(object item, Subscription subscription)
+ {
+ if (Volatile.Read(ref _disposed) != 0) return;
+ try
+ {
+ if (item is not Outlook.MailItem mail) return;
+ var entryId = SafeGet(() => mail.EntryID);
+ if (string.IsNullOrWhiteSpace(entryId)) return;
+
+ lock (_processedLock)
+ {
+ if (!_processedEntryIds.Add(entryId!)) return;
+ if (_processedEntryIds.Count > 2000)
+ {
+ // Trim cold entries occasionally.
+ _processedEntryIds.Clear();
+ _processedEntryIds.Add(entryId!);
+ }
+ }
+
+ _ = ProcessAsync(mail, subscription);
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "FolderWatcher.OnItemAdd failed");
+ }
+ }
+
+ private async Task ProcessAsync(Outlook.MailItem mail, Subscription subscription)
+ {
+ try
+ {
+ var sender = SafeGet(() => mail.SenderEmailAddress);
+ if (string.IsNullOrWhiteSpace(sender) || !sender!.Contains("@"))
+ {
+ return;
+ }
+
+ var match = await _matching.LookupAsync(sender).ConfigureAwait(true);
+
+ var spec = subscription.Spec;
+ var threshold = spec.ConfidenceThreshold;
+ var hasConfidentMatch =
+ match != null &&
+ !match.IsAmbiguous &&
+ match.Contact != null;
+
+ var confidence = hasConfidentMatch ? 1.0 : 0.0;
+
+ FilingTarget? target = null;
+ if (hasConfidentMatch)
+ {
+ target = ResolveTargetFromMatch(match!, spec);
+ }
+ else if (!string.IsNullOrWhiteSpace(spec.DefaultParentId) && !string.IsNullOrWhiteSpace(spec.DefaultParentType))
+ {
+ target = new FilingTarget(spec.DefaultParentType!, spec.DefaultParentId!, spec.DefaultParentType!);
+ }
+
+ if (target == null || confidence < threshold || spec.Mode == WatchMode.NotifyOnly)
+ {
+ StampCategoryOnUiThread(mail, NeedsFilingCategory);
+ _logger.Information("FolderWatcher: notify-only for {Sender} in {Path}", sender, spec.FolderPath);
+ return;
+ }
+
+ var envelope = MailItemExtractor.Extract(mail);
+ var summary = await _filing.FileAsync(new[] { envelope }, target).ConfigureAwait(true);
+ if (summary.FiledCount == 1)
+ {
+ StampCategoryOnUiThread(mail, "Filed: " + target.ParentName);
+ _logger.Information("FolderWatcher: auto-filed {Sender} → {Type}/{Id}",
+ sender, target.ParentType, target.ParentId);
+ }
+ else if (summary.QueuedCount == 1)
+ {
+ _logger.Information("FolderWatcher: auto-file queued ({Path}, sender={Sender})", spec.FolderPath, sender);
+ }
+ else
+ {
+ StampCategoryOnUiThread(mail, NeedsFilingCategory);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "FolderWatcher.ProcessAsync failed");
+ }
+ }
+
+ private static FilingTarget? ResolveTargetFromMatch(MatchResult match, WatchedFolder spec)
+ {
+ if (match.Contact == null) return null;
+
+ if (match.RecentCases != null && match.RecentCases.Count > 0)
+ {
+ var first = match.RecentCases[0];
+ return new FilingTarget("Case", first.Id, first.Name ?? first.Number ?? first.Id);
+ }
+ return new FilingTarget("Contact", match.Contact.Id, match.Contact.Name ?? match.Contact.EmailAddress ?? match.Contact.Id);
+ }
+
+ private void StampCategoryOnUiThread(Outlook.MailItem mail, string category)
+ {
+ void Stamp()
+ {
+ try
+ {
+ var existing = mail.Categories ?? string.Empty;
+ if (existing.IndexOf(category, StringComparison.Ordinal) >= 0) return;
+ mail.Categories = string.IsNullOrEmpty(existing) ? category : existing + "; " + category;
+ mail.Save();
+ }
+ catch (Exception ex)
+ {
+ _logger.Warning(ex, "FolderWatcher: category stamp failed");
+ }
+ }
+
+ if (_dispatcher.CheckAccess()) Stamp();
+ else _dispatcher.BeginInvoke((Action)Stamp);
+ }
+
+ private void TearDown()
+ {
+ foreach (var sub in _subscriptions)
+ {
+ try { if (sub.Handler != null) sub.Items.ItemAdd -= sub.Handler; } catch { /* ignore */ }
+ try { if (Marshal.IsComObject(sub.Items)) Marshal.ReleaseComObject(sub.Items); } catch { /* ignore */ }
+ try { if (Marshal.IsComObject(sub.Folder)) Marshal.ReleaseComObject(sub.Folder); } catch { /* ignore */ }
+ }
+ _subscriptions.Clear();
+ }
+
+ public void Dispose()
+ {
+ if (Interlocked.Exchange(ref _disposed, 1) != 0) return;
+ TearDown();
+ }
+
+ private static T? SafeGet(Func getter) where T : class
+ {
+ try { return getter(); } catch { return null; }
+ }
+
+ private sealed class Subscription
+ {
+ public WatchedFolder Spec { get; }
+ public Outlook.Folder Folder { get; }
+ public Outlook.Items Items { get; }
+ public Outlook.ItemsEvents_ItemAddEventHandler? Handler { get; set; }
+
+ public Subscription(WatchedFolder spec, Outlook.Folder folder, Outlook.Items items)
+ {
+ Spec = spec;
+ Folder = folder;
+ Items = items;
+ }
+ }
+ }
+}