From a05b8114d43e16b454b4ff06f5736215f8fb6a51 Mon Sep 17 00:00:00 2001 From: PointStar Date: Mon, 11 May 2026 15:51:03 +0300 Subject: [PATCH] =?UTF-8?q?feat(folders):=20Task=20#6=20=E2=80=94=20per-fo?= =?UTF-8?q?lder=20auto-sync=20with=20confidence=20gate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host: - FolderResolver: walks Outlook Stores/Folders by stored StoreId + backslash-separated FolderPath; RCWs released at each hop - FolderWatcher: rooted Items references in a Subscription list (preventing the GC from silently dropping the COM event sink), HashSet dedup, marshals category stamping back to the WPF dispatcher. Per-folder mode: AutoFile when MatchingService returns a confident unique match AND confidence ≥ ConfidenceThreshold; otherwise stamps "Needs filing" - FolderTreeBuilder: one-shot STA traversal (max depth 4, max 500 entries) into Core-side FolderTreeNode list UI: - FolderTreeNode in Core for cross-project transport - FolderRowViewModel: per-row IsWatched / Mode / ConfidenceThreshold - SettingsViewModel.LoadFolderTree(tree) merges existing AppSettings.WatchedFolders into the loaded list - Save now writes WatchedFolders alongside URL/creds - SettingsDialog: new "תיקיות" tab with a DataGrid (checkbox + mode combo per folder) AddInHost: - Builds FolderResolver + FolderWatcher inside TryInitializeCrm, applies Settings.WatchedFolders on startup and after Save - LaunchSettings: invokes FolderTreeBuilder, passes the flat list to the ViewModel, and re-applies the watch list after the dialog closes Core/UI/Tests green (38 tests). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Models/FolderTreeNode.cs | 24 ++ .../Dialogs/SettingsDialog.xaml | 33 +++ .../ViewModels/FolderRowViewModel.cs | 42 +++ .../ViewModels/SettingsViewModel.cs | 46 ++- src/OutlookAddin/OutlookAddin.csproj | 9 + src/OutlookAddin/Services/AddInHost.cs | 24 ++ src/OutlookAddin/Services/FolderResolver.cs | 151 ++++++++++ .../Services/FolderTreeBuilder.cs | 120 ++++++++ src/OutlookAddin/Services/FolderWatcher.cs | 262 ++++++++++++++++++ 9 files changed, 710 insertions(+), 1 deletion(-) create mode 100644 src/OutlookAddin.Core/Models/FolderTreeNode.cs create mode 100644 src/OutlookAddin.UI/ViewModels/FolderRowViewModel.cs create mode 100644 src/OutlookAddin/Services/FolderResolver.cs create mode 100644 src/OutlookAddin/Services/FolderTreeBuilder.cs create mode 100644 src/OutlookAddin/Services/FolderWatcher.cs 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; + } + } + } +}