feat(folders): Task #6 — per-folder auto-sync with confidence gate

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<EntryID> 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) <noreply@anthropic.com>
This commit is contained in:
PointStar
2026-05-11 15:51:03 +03:00
parent 0964439441
commit a05b8114d4
9 changed files with 710 additions and 1 deletions
@@ -0,0 +1,24 @@
using System.Collections.Generic;
namespace MarcusLaw.OutlookAddin.Core.Models
{
/// <summary>
/// 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).
/// </summary>
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<FolderTreeNode> Children { get; set; } = new List<FolderTreeNode>();
}
}
@@ -63,6 +63,39 @@
</Grid>
</TabItem>
<TabItem Header="תיקיות">
<Grid Margin="6">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Margin="2,2,2,6" TextWrapping="Wrap"
Text="סמן תיקיות לניטור. במצב 'אוטומטי' התוסף יתייק לבד מיילים שזוהו בוודאות, אחרת רק יסמן 'נדרש תיוק'." />
<DataGrid Grid.Row="1" ItemsSource="{Binding Folders}"
AutoGenerateColumns="False"
HeadersVisibility="Column"
GridLinesVisibility="Horizontal"
CanUserAddRows="False" CanUserDeleteRows="False"
FlowDirection="RightToLeft">
<DataGrid.Columns>
<DataGridCheckBoxColumn Header="נטור" Binding="{Binding IsWatched, UpdateSourceTrigger=PropertyChanged}" Width="60" />
<DataGridTextColumn Header="תיקייה" Binding="{Binding DisplayLabel}" IsReadOnly="True" Width="*" />
<DataGridTextColumn Header="חשבון" Binding="{Binding Node.StoreDisplayName}" IsReadOnly="True" Width="140" />
<DataGridTemplateColumn Header="מצב" Width="160">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<ComboBox SelectedValue="{Binding Mode, UpdateSourceTrigger=PropertyChanged}" SelectedValuePath="Tag">
<ComboBoxItem Content="התרעה בלבד" Tag="NotifyOnly" />
<ComboBoxItem Content="תיוק אוטומטי" Tag="AutoFile" />
</ComboBox>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</TabItem>
<TabItem Header="אודות">
<StackPanel Margin="16">
<TextBlock Text="Marcus-Law OutlookAddin" FontWeight="Bold" FontSize="16" />
@@ -0,0 +1,42 @@
using CommunityToolkit.Mvvm.ComponentModel;
using MarcusLaw.OutlookAddin.Core.Configuration;
using MarcusLaw.OutlookAddin.Core.Models;
namespace MarcusLaw.OutlookAddin.UI.ViewModels
{
/// <summary>
/// One row in the Folders tab of the Settings dialog. Wraps a
/// <see cref="FolderTreeNode"/> with three observable fields the user
/// can toggle: IsWatched, Mode, ConfidenceThreshold.
/// </summary>
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
};
}
}
@@ -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<FolderRowViewModel> Folders { get; } = new ObservableCollection<FolderRowViewModel>();
public SettingsViewModel(
AppSettings settings,
SettingsManager settingsManager,
@@ -69,6 +74,37 @@ namespace MarcusLaw.OutlookAddin.UI.ViewModels
VersionLine = $"גרסה {version}";
}
/// <summary>
/// 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.
/// </summary>
public void LoadFolderTree(IReadOnlyList<FolderTreeNode> tree)
{
Folders.Clear();
if (tree == null) return;
var watchedLookup = new Dictionary<string, WatchedFolder>(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);
+9
View File
@@ -244,6 +244,15 @@
<Compile Include="Services\ExplorerSelectionHandler.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\FolderResolver.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\FolderTreeBuilder.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\FolderWatcher.cs">
<SubType>Code</SubType>
</Compile>
<Compile Include="Services\MailItemExtractor.cs">
<SubType>Code</SubType>
</Compile>
+24
View File
@@ -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;
}
+151
View File
@@ -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));
}
/// <summary>
/// Resolves a stored "<see cref="WatchedFolder.StoreId"/> + <see cref="WatchedFolder.FolderPath"/>"
/// pair back to a live <see cref="Outlook.Folder"/>. Path format is
/// <c>\\Inbox\\Clients</c> (backslash-separated, starting with the
/// store root folder). Caller owns the returned RCW and must release it.
/// </summary>
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<string> 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<string> SplitPath(string folderPath)
{
var trimmed = folderPath.TrimStart('\\');
if (string.IsNullOrEmpty(trimmed)) return Array.Empty<string>();
return trimmed.Split(new[] { '\\' }, StringSplitOptions.RemoveEmptyEntries);
}
}
}
@@ -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
{
/// <summary>
/// One-shot, STA-bound traversal of the user's mailbox tree. Emits a
/// flat list of <see cref="FolderTreeNode"/> values that the Settings
/// dialog can data-bind without keeping any Outlook COM references alive.
/// </summary>
public static class FolderTreeBuilder
{
private const int MaxDepth = 4;
private const int MaxFolders = 500;
public static IReadOnlyList<FolderTreeNode> BuildFlatList(Outlook.Application app, ILogger logger)
{
var result = new List<FolderTreeNode>();
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<FolderTreeNode> 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);
}
}
}
}
+262
View File
@@ -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
{
/// <summary>
/// Subscribes to <see cref="Outlook.Items.ItemAdd"/> 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 <see cref="IMatchingService"/>.
/// 3. If exactly one unambiguous match and the folder is in
/// <see cref="WatchMode.AutoFile"/>, file via <see cref="IFilingService"/>.
/// 4. Otherwise stamp the "Needs filing" Outlook category.
///
/// CRITICAL: the <c>Items</c> collections MUST stay rooted, otherwise
/// the GC will quietly drop the COM subscription. We hold them in
/// <c>_subscriptions</c> for the lifetime of the watcher.
/// </summary>
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<Subscription> _subscriptions = new List<Subscription>();
private readonly HashSet<string> _processedEntryIds = new HashSet<string>(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<WatchedFolder> 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<T>(Func<T> 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;
}
}
}
}