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:
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user