Merge pull request 'General error: cancel, multiple popup and startup rule to see the folders' (#2) from MemoryAdjustments into master

Reviewed-on: #2
This commit was merged in pull request #2.
This commit is contained in:
2026-03-30 20:02:49 +01:00
2 changed files with 180 additions and 79 deletions
+49 -52
View File
@@ -4,7 +4,7 @@ using System.Windows.Forms;
using System.IO; using System.IO;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading;
using Microsoft.Win32; using Microsoft.Win32;
using Outlook = Microsoft.Office.Interop.Outlook; using Outlook = Microsoft.Office.Interop.Outlook;
@@ -44,6 +44,10 @@ namespace OutlookCaseHelper
private InputForm? _removeRuleForm; private InputForm? _removeRuleForm;
private AboutForm? _aboutForm; private AboutForm? _aboutForm;
// FIX #3: Guard to prevent multiple ProcessEmail dialogs from opening
// (e.g., hotkey spam or rapid tray double-clicks)
private Form? _activeProcessDialog;
// --- Constructor & Init --- // --- Constructor & Init ---
public Form1() public Form1()
@@ -209,6 +213,7 @@ namespace OutlookCaseHelper
} }
// --- Singleton Window Helper --- // --- Singleton Window Helper ---
// FIX #6: Now actively used by ViewRules_Click and About_Click
private void ShowSingletonForm<T>(Func<T?> getter, Action<T?> setter, Func<T> create) where T : Form private void ShowSingletonForm<T>(Func<T?> getter, Action<T?> setter, Func<T> create) where T : Form
{ {
@@ -269,43 +274,43 @@ namespace OutlookCaseHelper
_settingsForm.Show(); _settingsForm.Show();
} }
// FIX #6: Using ShowSingletonForm helper instead of duplicated manual logic
private void ViewRules_Click(object? sender, EventArgs e) private void ViewRules_Click(object? sender, EventArgs e)
{ => ShowSingletonForm(
if (_dashboardForm != null && !_dashboardForm.IsDisposed) () => _dashboardForm,
{ v => _dashboardForm = v,
_dashboardForm.BringToFront(); () => { var f = new DashboardForm(outlookHelper); f.Owner = this; return f; });
_dashboardForm.WindowState = FormWindowState.Normal;
return;
}
_dashboardForm = new DashboardForm(outlookHelper);
_dashboardForm.Owner = this;
_dashboardForm.FormClosed += (s, args) => _dashboardForm = null;
_dashboardForm.Show();
}
// FIX #6: Using ShowSingletonForm helper instead of duplicated manual logic
private void About_Click(object? sender, EventArgs e) private void About_Click(object? sender, EventArgs e)
{ => ShowSingletonForm(
if (_aboutForm != null && !_aboutForm.IsDisposed) () => _aboutForm,
{ v => _aboutForm = v,
_aboutForm.BringToFront(); () => new AboutForm());
_aboutForm.WindowState = FormWindowState.Normal;
return;
}
_aboutForm = new AboutForm();
_aboutForm.FormClosed += (s, args) => _aboutForm = null;
_aboutForm.Show();
}
// FIX #5: Use STA thread instead of Task.Run to avoid COM threading violation.
// Outlook COM objects require STA (Single-Threaded Apartment).
// Task.Run uses ThreadPool which is MTA, causing silent failures or crashes.
private void RunAllRules_Click(object? sender, EventArgs e) private void RunAllRules_Click(object? sender, EventArgs e)
{ {
try try
{ {
trayIcon.ShowBalloonTip(2000, "Outlook Case Manager", "Running all rules...", ToolTipIcon.Info); trayIcon.ShowBalloonTip(2000, "Outlook Case Manager", "Running all rules...", ToolTipIcon.Info);
Task.Run(() => var thread = new Thread(() =>
{ {
outlookHelper.RunAllRules(); try
this.Invoke(() => trayIcon.ShowBalloonTip(3000, "Outlook Case Manager", "All rules applied successfully!", ToolTipIcon.Info)); {
outlookHelper.RunAllRules();
this.Invoke(() => trayIcon.ShowBalloonTip(3000, "Outlook Case Manager", "All rules applied successfully!", ToolTipIcon.Info));
}
catch (Exception ex)
{
this.Invoke(() => MessageBox.Show($"Error running rules: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error));
}
}); });
thread.SetApartmentState(ApartmentState.STA);
thread.IsBackground = true;
thread.Start();
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -315,8 +320,16 @@ namespace OutlookCaseHelper
// --- Rule Actions --- // --- Rule Actions ---
// FIX #3: Added _activeProcessDialog guard to prevent multiple dialogs opening
// when hotkey is pressed rapidly or tray icon is double-clicked quickly.
private void ProcessEmail_Click(object? sender, EventArgs e) private void ProcessEmail_Click(object? sender, EventArgs e)
{ {
if (_activeProcessDialog != null && !_activeProcessDialog.IsDisposed)
{
_activeProcessDialog.BringToFront();
return;
}
try try
{ {
var trackingId = outlookHelper.GetSelectedEmailTrackingId(); var trackingId = outlookHelper.GetSelectedEmailTrackingId();
@@ -340,6 +353,9 @@ namespace OutlookCaseHelper
} }
var ruleForm = new CreateRuleForm(trackingId, readonlyId: true); var ruleForm = new CreateRuleForm(trackingId, readonlyId: true);
_activeProcessDialog = ruleForm;
ruleForm.FormClosed += (s, args) => _activeProcessDialog = null;
if (ruleForm.ShowDialog() != DialogResult.OK) return; if (ruleForm.ShowDialog() != DialogResult.OK) return;
HandleCreate(trackingId, ruleForm.FolderName); HandleCreate(trackingId, ruleForm.FolderName);
} }
@@ -517,7 +533,6 @@ namespace OutlookCaseHelper
{ {
listView.Items.Clear(); listView.Items.Clear();
var rules = outlookHelper.GetActiveRulesInfo(); var rules = outlookHelper.GetActiveRulesInfo();
if (rules.Count == 0) if (rules.Count == 0)
{ {
var empty = new ListViewItem("No active rules found."); var empty = new ListViewItem("No active rules found.");
@@ -525,7 +540,6 @@ namespace OutlookCaseHelper
listView.Items.Add(empty); listView.Items.Add(empty);
return; return;
} }
foreach (var rule in rules) foreach (var rule in rules)
{ {
var item = new ListViewItem(rule.FolderName); var item = new ListViewItem(rule.FolderName);
@@ -540,36 +554,28 @@ namespace OutlookCaseHelper
{ {
var ruleForm = new CreateRuleForm("", readonlyId: false); var ruleForm = new CreateRuleForm("", readonlyId: false);
if (ruleForm.ShowDialog() != DialogResult.OK) return; if (ruleForm.ShowDialog() != DialogResult.OK) return;
string trackingId = ruleForm.TrackingId; string trackingId = ruleForm.TrackingId;
string folderName = ruleForm.FolderName; string folderName = ruleForm.FolderName;
if (outlookHelper.FindRuleByTrackingId(trackingId) != null) if (outlookHelper.FindRuleByTrackingId(trackingId) != null)
{ MessageBox.Show($"Rule for TrackingID#{trackingId} already exists!", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } { MessageBox.Show($"Rule for TrackingID#{trackingId} already exists!", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
if (outlookHelper.ExistsInClosed(trackingId)) if (outlookHelper.ExistsInClosed(trackingId))
{ {
if (outlookHelper.ReopenFromClosed(trackingId)) if (outlookHelper.ReopenFromClosed(trackingId))
{ MessageBox.Show($"Case reopened!\n\nTrackingID: {trackingId}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); } { MessageBox.Show($"Case reopened!\n\nTrackingID: {trackingId}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else else MessageBox.Show("Error reopening case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show("Error reopening case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
return; return;
} }
if (outlookHelper.CreateFolderAndMoveEmails(trackingId, folderName)) if (outlookHelper.CreateFolderAndMoveEmails(trackingId, folderName))
{ MessageBox.Show($"Rule created!\n\nFolder: {folderName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); } { MessageBox.Show($"Rule created!\n\nFolder: {folderName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else else MessageBox.Show("Error creating rule. Make sure Outlook is open.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show("Error creating rule. Make sure Outlook is open.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
private void BtnRename_Click(object? sender, EventArgs e) private void BtnRename_Click(object? sender, EventArgs e)
{ {
if (listView.SelectedItems.Count == 0) if (listView.SelectedItems.Count == 0)
{ MessageBox.Show("Please select a rule to rename.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } { MessageBox.Show("Please select a rule to rename.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo; var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo;
if (rule == null) return; if (rule == null) return;
var inputForm = new Form var inputForm = new Form
{ {
Text = "Rename Rule", Text = "Rename Rule",
@@ -582,42 +588,33 @@ namespace OutlookCaseHelper
StartPosition = FormStartPosition.CenterScreen StartPosition = FormStartPosition.CenterScreen
}; };
try { inputForm.Icon = new Icon(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "casenew.ico")); } catch { } try { inputForm.Icon = new Icon(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "casenew.ico")); } catch { }
var lbl = new Label { Text = $"New name for TrackingID#{rule.TrackingId}:", Left = 20, Top = 15, Width = 370, Height = 20 }; var lbl = new Label { Text = $"New name for TrackingID#{rule.TrackingId}:", Left = 20, Top = 15, Width = 370, Height = 20 };
var txt = new TextBox { Left = 20, Top = 40, Width = 370, Height = 24, Text = rule.FolderName }; var txt = new TextBox { Left = 20, Top = 40, Width = 370, Height = 24, Text = rule.FolderName };
var btnOk = new Button { Text = "Rename", Left = 210, Top = 80, Width = 80, DialogResult = DialogResult.OK }; var btnOk = new Button { Text = "Rename", Left = 210, Top = 80, Width = 80, DialogResult = DialogResult.OK };
var btnCancel = new Button { Text = "Cancel", Left = 300, Top = 80, Width = 80, DialogResult = DialogResult.Cancel }; var btnCancel = new Button { Text = "Cancel", Left = 300, Top = 80, Width = 80, DialogResult = DialogResult.Cancel };
btnCancel.Click += (s, e) => inputForm.Close(); btnCancel.Click += (s, e) => inputForm.Close();
inputForm.Controls.AddRange(new Control[] { lbl, txt, btnOk, btnCancel }); inputForm.Controls.AddRange(new Control[] { lbl, txt, btnOk, btnCancel });
inputForm.AcceptButton = btnOk; inputForm.AcceptButton = btnOk; inputForm.CancelButton = btnCancel;
inputForm.CancelButton = btnCancel;
if (inputForm.ShowDialog() != DialogResult.OK) return; if (inputForm.ShowDialog() != DialogResult.OK) return;
string newName = txt.Text.Trim(); string newName = txt.Text.Trim();
if (string.IsNullOrEmpty(newName) || newName == rule.FolderName) return; if (string.IsNullOrEmpty(newName) || newName == rule.FolderName) return;
if (outlookHelper.RenameRule(rule.FolderName, newName)) if (outlookHelper.RenameRule(rule.FolderName, newName))
{ MessageBox.Show($"Rule renamed!\n\n{rule.FolderName} {newName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); } { MessageBox.Show($"Rule renamed!\n\n{rule.FolderName} -> {newName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else else MessageBox.Show("Error renaming rule.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show("Error renaming rule.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
private void BtnClose_Click(object? sender, EventArgs e) private void BtnClose_Click(object? sender, EventArgs e)
{ {
if (listView.SelectedItems.Count == 0) if (listView.SelectedItems.Count == 0)
{ MessageBox.Show("Please select a rule to close.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; } { MessageBox.Show("Please select a rule to close.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo; var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo;
if (rule == null) return; if (rule == null) return;
if (MessageBox.Show( if (MessageBox.Show(
$"Are you sure you want to close this case?\n\nFolder: {rule.FolderName}\n\nThe folder will be moved to Closed.", $"Are you sure you want to close this case?\n\nFolder: {rule.FolderName}\n\nThe folder will be moved to Closed.",
"Close Case", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return; "Close Case", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes) return;
if (outlookHelper.RemoveRuleAndMoveToClosed(rule.FolderName)) if (outlookHelper.RemoveRuleAndMoveToClosed(rule.FolderName))
{ MessageBox.Show("Case closed!\n\nFolder moved to Closed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); } { MessageBox.Show("Case closed!\n\nFolder moved to Closed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else else MessageBox.Show("Error closing case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show("Error closing case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
} }
private void BtnReload_Click(object? sender, EventArgs e) private void BtnReload_Click(object? sender, EventArgs e)
+131 -27
View File
@@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.IO; using System.IO;
using System.Text.Json; using System.Text.Json;
@@ -18,6 +20,12 @@ namespace OutlookCaseHelper
private Outlook.Items? sentItems; private Outlook.Items? sentItems;
private bool disposed = false; private bool disposed = false;
// PERF FIX: Cache "Active" and "Closed" folder references to avoid
// traversing the full folder tree on every operation.
// Cache is validated before each use (COM object can go stale).
private Outlook.Folder? _cachedActiveFolder;
private Outlook.Folder? _cachedClosedFolder;
public class RuleInfo public class RuleInfo
{ {
public string FolderName { get; set; } = ""; public string FolderName { get; set; } = "";
@@ -32,6 +40,9 @@ namespace OutlookCaseHelper
"OutlookCaseHelper", "active_rules.json"); "OutlookCaseHelper", "active_rules.json");
Directory.CreateDirectory(Path.GetDirectoryName(rulesFilePath)!); Directory.CreateDirectory(Path.GetDirectoryName(rulesFilePath)!);
LoadRules(); LoadRules();
// FIX #2: Scan inbox on startup so emails that arrived while the app
// was closed are moved into their correct folders immediately.
ScanInboxOnStartup();
} }
// --- Dispose --- // --- Dispose ---
@@ -46,23 +57,25 @@ namespace OutlookCaseHelper
if (inboxItems != null) if (inboxItems != null)
{ {
inboxItems.ItemAdd -= OnEmailReceived; inboxItems.ItemAdd -= OnEmailReceived;
System.Runtime.InteropServices.Marshal.ReleaseComObject(inboxItems); Marshal.ReleaseComObject(inboxItems);
inboxItems = null; inboxItems = null;
} }
if (sentItems != null) if (sentItems != null)
{ {
sentItems.ItemAdd -= OnEmailSent; sentItems.ItemAdd -= OnEmailSent;
System.Runtime.InteropServices.Marshal.ReleaseComObject(sentItems); Marshal.ReleaseComObject(sentItems);
sentItems = null; sentItems = null;
} }
_cachedActiveFolder = null;
_cachedClosedFolder = null;
if (outlookNamespace != null) if (outlookNamespace != null)
{ {
System.Runtime.InteropServices.Marshal.ReleaseComObject(outlookNamespace); Marshal.ReleaseComObject(outlookNamespace);
outlookNamespace = null; outlookNamespace = null;
} }
if (outlookApp != null) if (outlookApp != null)
{ {
System.Runtime.InteropServices.Marshal.ReleaseComObject(outlookApp); Marshal.ReleaseComObject(outlookApp);
outlookApp = null; outlookApp = null;
} }
} }
@@ -95,7 +108,7 @@ namespace OutlookCaseHelper
try try
{ {
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail); var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (inbox != null) if (inbox != null)
{ {
@@ -114,7 +127,7 @@ namespace OutlookCaseHelper
// --- Email Events --- // --- Email Events ---
private void OnEmailReceived(object item) => MoveEmailIfRuleExists(item); private void OnEmailReceived(object item) => MoveEmailIfRuleExists(item);
private void OnEmailSent(object item) => MoveEmailIfRuleExists(item); private void OnEmailSent(object item) => MoveEmailIfRuleExists(item);
private void MoveEmailIfRuleExists(object item) private void MoveEmailIfRuleExists(object item)
{ {
@@ -123,14 +136,15 @@ namespace OutlookCaseHelper
if (outlookNamespace == null || activeRules.Count == 0) return; if (outlookNamespace == null || activeRules.Count == 0) return;
if (item is not Outlook.MailItem mail || mail.Subject == null) return; if (item is not Outlook.MailItem mail || mail.Subject == null) return;
// PERF FIX: Resolve activeFolder once outside the loop
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return;
foreach (var folderName in activeRules) foreach (var folderName in activeRules)
{ {
string trackingId = ExtractTrackingId(folderName); string trackingId = ExtractTrackingId(folderName);
if (!mail.Subject.Contains($"TrackingID#{trackingId}")) continue; if (!mail.Subject.Contains($"TrackingID#{trackingId}")) continue;
var activeFolder = FindFolderByNameAnywhere("Active");
if (activeFolder == null) return;
var target = GetOrCreateFolder(activeFolder, folderName); var target = GetOrCreateFolder(activeFolder, folderName);
mail.Move(target); mail.Move(target);
break; break;
@@ -153,10 +167,21 @@ namespace OutlookCaseHelper
public void ReloadRules() public void ReloadRules()
{ {
activeRules.Clear(); activeRules.Clear();
// FIX: Invalidate folder cache when rules are reloaded
_cachedActiveFolder = null;
_cachedClosedFolder = null;
LoadRules(); LoadRules();
// FIX #7: Prune stale rules whose folders no longer exist in Outlook
PruneStaleRules();
} }
public void ProcessActiveRules() { } // FIX #1: ProcessActiveRules was empty — it now delegates to RunAllRules
// so the 60-second timer actually does something useful.
public void ProcessActiveRules()
{
if (activeRules.Count == 0) return;
RunAllRules();
}
private void LoadRules() private void LoadRules()
{ {
@@ -175,8 +200,36 @@ namespace OutlookCaseHelper
catch { } catch { }
} }
// FIX #7: Remove rules whose folders no longer exist in Outlook.
// Prevents infinite silent failures in RunAllRules and GetActiveRulesInfo
// when a folder was deleted manually from Outlook.
private void PruneStaleRules()
{
if (!EnsureOutlookConnected()) return;
try
{
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return;
var stale = activeRules
.Where(r => GetFolder(activeFolder, r) == null)
.ToList();
if (stale.Count == 0) return;
foreach (var r in stale)
activeRules.Remove(r);
SaveRules();
}
catch { }
}
// --- Outlook Operations --- // --- Outlook Operations ---
// FIX #4: Regex was @"TrackingID#(\\d+)" in a verbatim string.
// In a verbatim string, \\d is a literal backslash + 'd', NOT the \d digit class.
// Fixed to @"TrackingID#(\d+)" so it correctly matches numeric IDs.
public string? GetSelectedEmailTrackingId() public string? GetSelectedEmailTrackingId()
{ {
if (!EnsureOutlookConnected()) return null; if (!EnsureOutlookConnected()) return null;
@@ -202,7 +255,7 @@ namespace OutlookCaseHelper
try try
{ {
if (!EnsureOutlookConnected()) return false; if (!EnsureOutlookConnected()) return false;
var closed = FindFolderByNameAnywhere("Closed"); var closed = GetCachedClosedFolder();
return closed != null && GetFolderStartingWith(closed, trackingId) != null; return closed != null && GetFolderStartingWith(closed, trackingId) != null;
} }
catch { return false; } catch { return false; }
@@ -213,23 +266,27 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false; if (!EnsureOutlookConnected()) return false;
try try
{ {
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) if (activeFolder == null)
{ {
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false; if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases"); var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active"); activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
} }
var target = GetOrCreateFolder(activeFolder, folderName); var target = GetOrCreateFolder(activeFolder, folderName);
// FIX: Correct DASL filter — original had \\\" producing \"
// (backslash+quote), but Outlook DASL needs plain double quotes.
string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'"; string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'";
var inbox2 = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox2 = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail); var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (inbox2 != null) MoveFilteredEmails(inbox2, filter, target); if (inbox2 != null) MoveFilteredEmails(inbox2, filter, target);
if (sent != null) MoveFilteredEmails(sent, filter, target); if (sent != null) MoveFilteredEmails(sent, filter, target);
if (inbox2 != null) if (inbox2 != null)
{ {
@@ -258,19 +315,20 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false; if (!EnsureOutlookConnected()) return false;
try try
{ {
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return false; if (activeFolder == null) return false;
var folder = GetFolder(activeFolder, folderName); var folder = GetFolder(activeFolder, folderName);
if (folder == null) return false; if (folder == null) return false;
var closedFolder = FindFolderByNameAnywhere("Closed"); var closedFolder = GetCachedClosedFolder();
if (closedFolder == null) if (closedFolder == null)
{ {
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false; if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases"); var cases = GetOrCreateFolder(inbox, "Cases");
closedFolder = GetOrCreateFolder(cases, "Closed"); closedFolder = GetOrCreateFolder(cases, "Closed");
_cachedClosedFolder = closedFolder;
} }
folder.MoveTo(closedFolder); folder.MoveTo(closedFolder);
@@ -286,7 +344,7 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false; if (!EnsureOutlookConnected()) return false;
try try
{ {
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
var folder = GetFolder(activeFolder, oldFolderName); var folder = GetFolder(activeFolder, oldFolderName);
if (folder == null) return false; if (folder == null) return false;
@@ -304,7 +362,7 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false; if (!EnsureOutlookConnected()) return false;
try try
{ {
var closedFolder = FindFolderByNameAnywhere("Closed"); var closedFolder = GetCachedClosedFolder();
if (closedFolder == null) return false; if (closedFolder == null) return false;
var closedTracking = GetFolderStartingWith(closedFolder, trackingId); var closedTracking = GetFolderStartingWith(closedFolder, trackingId);
@@ -312,13 +370,14 @@ namespace OutlookCaseHelper
string existingName = closedTracking.Name; string existingName = closedTracking.Name;
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) if (activeFolder == null)
{ {
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false; if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases"); var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active"); activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
} }
var existingActive = GetFolder(activeFolder, existingName); var existingActive = GetFolder(activeFolder, existingName);
@@ -351,8 +410,8 @@ namespace OutlookCaseHelper
{ {
try try
{ {
var active = FindFolderByNameAnywhere("Active"); var active = GetCachedActiveFolder();
var moved = GetFolderStartingWith(active!, trackingId); var moved = GetFolderStartingWith(active!, trackingId);
if (moved != null) { activeRules.Add(moved.Name); SaveRules(); return true; } if (moved != null) { activeRules.Add(moved.Name); SaveRules(); return true; }
} }
catch { } catch { }
@@ -368,19 +427,21 @@ namespace OutlookCaseHelper
try try
{ {
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail); var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (inbox == null) return; if (inbox == null) return;
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) if (activeFolder == null)
{ {
var cases = GetOrCreateFolder(inbox, "Cases"); var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active"); activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
} }
foreach (var folderName in activeRules) foreach (var folderName in activeRules)
{ {
string trackingId = ExtractTrackingId(folderName); string trackingId = ExtractTrackingId(folderName);
// FIX: Correct DASL filter syntax (plain double quotes)
string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'"; string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'";
var target = GetOrCreateFolder(activeFolder, folderName); var target = GetOrCreateFolder(activeFolder, folderName);
@@ -399,13 +460,15 @@ namespace OutlookCaseHelper
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox); var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return; if (inbox == null) return;
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) if (activeFolder == null)
{ {
var cases = GetOrCreateFolder(inbox, "Cases"); var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active"); activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
} }
// PERF FIX: Build the full folder list once, outside the per-rule loop
var allFolders = new List<Outlook.Folder>(); var allFolders = new List<Outlook.Folder>();
if (inbox.Parent is Outlook.Folder root) if (inbox.Parent is Outlook.Folder root)
GetAllFolders(root, allFolders, activeFolder.EntryID); GetAllFolders(root, allFolders, activeFolder.EntryID);
@@ -413,6 +476,7 @@ namespace OutlookCaseHelper
foreach (var folderName in activeRules) foreach (var folderName in activeRules)
{ {
string trackingId = ExtractTrackingId(folderName); string trackingId = ExtractTrackingId(folderName);
// FIX: Correct DASL filter syntax (plain double quotes)
string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'"; string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'";
var target = GetOrCreateFolder(activeFolder, folderName); var target = GetOrCreateFolder(activeFolder, folderName);
@@ -423,13 +487,15 @@ namespace OutlookCaseHelper
catch { } catch { }
} }
// PERF FIX: Resolve activeFolder once before the loop instead of inside it.
// Original called EnsureOutlookConnected + FindFolderByNameAnywhere per iteration.
public List<RuleInfo> GetActiveRulesInfo() public List<RuleInfo> GetActiveRulesInfo()
{ {
var result = new List<RuleInfo>(); var result = new List<RuleInfo>();
if (!EnsureOutlookConnected()) return result; if (!EnsureOutlookConnected()) return result;
try try
{ {
var activeFolder = FindFolderByNameAnywhere("Active"); var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return result; if (activeFolder == null) return result;
foreach (var folderName in activeRules) foreach (var folderName in activeRules)
@@ -447,6 +513,33 @@ namespace OutlookCaseHelper
return result; return result;
} }
// --- Folder Cache Helpers ---
// PERF FIX: Caches the "Active" folder reference. Validates the COM object
// before returning it; falls back to a fresh lookup if it has gone stale.
private Outlook.Folder? GetCachedActiveFolder()
{
if (_cachedActiveFolder != null)
{
try { var _ = _cachedActiveFolder.Name; return _cachedActiveFolder; }
catch { _cachedActiveFolder = null; }
}
_cachedActiveFolder = FindFolderByNameAnywhere("Active");
return _cachedActiveFolder;
}
// PERF FIX: Caches the "Closed" folder reference with same staleness guard.
private Outlook.Folder? GetCachedClosedFolder()
{
if (_cachedClosedFolder != null)
{
try { var _ = _cachedClosedFolder.Name; return _cachedClosedFolder; }
catch { _cachedClosedFolder = null; }
}
_cachedClosedFolder = FindFolderByNameAnywhere("Closed");
return _cachedClosedFolder;
}
// --- Folder Search (any hierarchy level) --- // --- Folder Search (any hierarchy level) ---
private Outlook.Folder? FindFolderByNameAnywhere(string name) private Outlook.Folder? FindFolderByNameAnywhere(string name)
@@ -477,22 +570,33 @@ namespace OutlookCaseHelper
// --- Helpers --- // --- Helpers ---
// FIX #4: Regex was @"^(\\d+)" in a verbatim string = literal "\\d".
// Fixed to @"^(\d+)" so it actually matches leading digits in folder names.
private string ExtractTrackingId(string folderName) private string ExtractTrackingId(string folderName)
{ {
var match = Regex.Match(folderName, @"^(\d+)"); var match = Regex.Match(folderName, @"^(\d+)");
return match.Success ? match.Groups[1].Value : folderName; return match.Success ? match.Groups[1].Value : folderName;
} }
// PERF FIX: Release the COM object returned by Restrict() after use.
// Failing to do so causes COM reference leaks when many rules are active.
private void MoveFilteredEmails(Outlook.Folder source, string filter, Outlook.Folder dest) private void MoveFilteredEmails(Outlook.Folder source, string filter, Outlook.Folder dest)
{ {
Outlook.Items? restricted = null;
try try
{ {
restricted = source.Items.Restrict(filter);
var toMove = new List<Outlook.MailItem>(); var toMove = new List<Outlook.MailItem>();
foreach (object item in source.Items.Restrict(filter)) foreach (object item in restricted)
if (item is Outlook.MailItem mail) toMove.Add(mail); if (item is Outlook.MailItem mail) toMove.Add(mail);
foreach (var mail in toMove) mail.Move(dest); foreach (var mail in toMove) mail.Move(dest);
} }
catch { } catch { }
finally
{
if (restricted != null)
Marshal.ReleaseComObject(restricted);
}
} }
private void GetAllFolders(Outlook.Folder parent, List<Outlook.Folder> result, string excludeEntryId) private void GetAllFolders(Outlook.Folder parent, List<Outlook.Folder> result, string excludeEntryId)
@@ -536,4 +640,4 @@ namespace OutlookCaseHelper
return null; return null;
} }
} }
} }