Compare commits

4 Commits

Author SHA1 Message Date
wellington f9cba95f78 Merge pull request 'General error: cancel, multiple popup and startup rule to see the folders' (#2) from MemoryAdjustments into master
Reviewed-on: #2
2026-03-30 20:02:49 +01:00
wellington c680bb5571 General error: cancel, multiple popup and startup rule to see the folders
Enhance OutlookCaseHelper with performance and stability fixes

Improved threading for Outlook COM objects to use STA, preventing threading violations. Added guards to manage dialog instances and introduced a helper method to reduce code duplication for form handling. Enhanced error handling for better user feedback and implemented caching for folder references to optimize performance. Refined rule management logic, fixed regex for tracking ID extraction, and ensured proper COM object management to prevent memory leaks. General code cleanup for improved readability and maintainability.
2026-03-30 19:58:15 +01:00
wellington 4d1be3cb98 Merge pull request 'Refactoring cancel buttons, also removing memory leak' (#1) from MemoryAdjustments into master
Reviewed-on: #1
2026-03-27 00:23:47 +00:00
wellington 9be729101b Refactoring cancel buttons, also removing memory leak 2026-03-27 00:14:20 +00:00
2 changed files with 352 additions and 129 deletions
+96 -64
View File
@@ -4,7 +4,7 @@ using System.Windows.Forms;
using System.IO;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Threading.Tasks;
using System.Threading;
using Microsoft.Win32;
using Outlook = Microsoft.Office.Interop.Outlook;
@@ -37,10 +37,16 @@ namespace OutlookCaseHelper
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OutlookCaseHelper", "hotkeys.json");
// Singleton windows
private DashboardForm? _dashboardForm;
private HotkeySettingsForm? _settingsForm;
private CreateRuleForm? _createRuleForm;
private InputForm? _removeRuleForm;
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 ---
@@ -67,6 +73,18 @@ namespace OutlookCaseHelper
monitorTimer.Start();
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
UnregisterHotKey(this.Handle, HOTKEY_CREATE);
UnregisterHotKey(this.Handle, HOTKEY_REMOVE);
monitorTimer.Stop();
monitorTimer.Dispose();
outlookHelper.Dispose();
trayIcon.Visible = false;
trayIcon.Dispose();
base.OnFormClosed(e);
}
// --- Hotkeys ---
protected override void OnHandleCreated(EventArgs e)
@@ -83,13 +101,6 @@ namespace OutlookCaseHelper
RegisterHotKey(this.Handle, HOTKEY_REMOVE, hotkeyRemoveMod, hotkeyRemoveKey);
}
protected override void OnFormClosed(FormClosedEventArgs e)
{
UnregisterHotKey(this.Handle, HOTKEY_CREATE);
UnregisterHotKey(this.Handle, HOTKEY_REMOVE);
base.OnFormClosed(e);
}
protected override void WndProc(ref Message m)
{
if (m.Msg == 0x0312)
@@ -198,11 +209,28 @@ namespace OutlookCaseHelper
private void Exit_Click(object? sender, EventArgs e)
{
monitorTimer.Stop();
trayIcon.Visible = false;
Application.Exit();
}
// --- 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
{
var field = getter();
if (field != null && !field.IsDisposed)
{
field.BringToFront();
field.WindowState = FormWindowState.Normal;
return;
}
var created = create();
setter(created);
created.FormClosed += (s, e) => setter(null);
created.Show();
}
// --- Tray Menu Handlers ---
private void ToggleStartup_Click(object? sender, EventArgs e)
@@ -246,31 +274,43 @@ namespace OutlookCaseHelper
_settingsForm.Show();
}
// FIX #6: Using ShowSingletonForm helper instead of duplicated manual logic
private void ViewRules_Click(object? sender, EventArgs e)
{
if (_dashboardForm != null && !_dashboardForm.IsDisposed)
{
_dashboardForm.BringToFront();
_dashboardForm.WindowState = FormWindowState.Normal;
return;
}
=> ShowSingletonForm(
() => _dashboardForm,
v => _dashboardForm = v,
() => { var f = new DashboardForm(outlookHelper); f.Owner = this; return f; });
_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)
=> ShowSingletonForm(
() => _aboutForm,
v => _aboutForm = v,
() => new AboutForm());
// 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)
{
try
{
trayIcon.ShowBalloonTip(2000, "Outlook Case Manager", "Running all rules...", ToolTipIcon.Info);
Task.Run(() =>
var thread = new Thread(() =>
{
outlookHelper.RunAllRules();
this.Invoke(() => trayIcon.ShowBalloonTip(3000, "Outlook Case Manager", "All rules applied successfully!", ToolTipIcon.Info));
try
{
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)
{
@@ -278,12 +318,18 @@ namespace OutlookCaseHelper
}
}
private void About_Click(object? sender, EventArgs e) => new AboutForm().ShowDialog();
// --- 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)
{
if (_activeProcessDialog != null && !_activeProcessDialog.IsDisposed)
{
_activeProcessDialog.BringToFront();
return;
}
try
{
var trackingId = outlookHelper.GetSelectedEmailTrackingId();
@@ -307,8 +353,10 @@ namespace OutlookCaseHelper
}
var ruleForm = new CreateRuleForm(trackingId, readonlyId: true);
if (ruleForm.ShowDialog() != DialogResult.OK) return;
_activeProcessDialog = ruleForm;
ruleForm.FormClosed += (s, args) => _activeProcessDialog = null;
if (ruleForm.ShowDialog() != DialogResult.OK) return;
HandleCreate(trackingId, ruleForm.FolderName);
}
catch (Exception ex) { MessageBox.Show($"Error: {ex.Message}", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error); }
@@ -334,7 +382,7 @@ namespace OutlookCaseHelper
string folderName = _createRuleForm.FolderName;
if (outlookHelper.FindRuleByTrackingId(trackingId) != null)
{ MessageBox.Show($"Rule for TrackingID#{trackingId} already exists!", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); }
MessageBox.Show($"Rule for TrackingID#{trackingId} already exists!", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning);
else if (outlookHelper.ExistsInClosed(trackingId))
HandleReopen(trackingId);
else
@@ -423,7 +471,7 @@ namespace OutlookCaseHelper
private void HandleRemove(string folderName)
{
if (outlookHelper.RemoveRuleAndMoveToClosed(folderName))
MessageBox.Show($"Rule removed!\n\nFolder moved to: Inbox > Cases > Closed\nFolder: {folderName}\nMonitoring stopped.",
MessageBox.Show($"Rule removed!\n\nFolder moved to Closed.\nFolder: {folderName}\nMonitoring stopped.",
"Success", MessageBoxButtons.OK, MessageBoxIcon.Information);
else
MessageBox.Show("Error removing rule. Check if folder exists.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
@@ -485,7 +533,6 @@ namespace OutlookCaseHelper
{
listView.Items.Clear();
var rules = outlookHelper.GetActiveRulesInfo();
if (rules.Count == 0)
{
var empty = new ListViewItem("No active rules found.");
@@ -493,7 +540,6 @@ namespace OutlookCaseHelper
listView.Items.Add(empty);
return;
}
foreach (var rule in rules)
{
var item = new ListViewItem(rule.FolderName);
@@ -508,39 +554,28 @@ namespace OutlookCaseHelper
{
var ruleForm = new CreateRuleForm("", readonlyId: false);
if (ruleForm.ShowDialog() != DialogResult.OK) return;
string trackingId = ruleForm.TrackingId;
string folderName = ruleForm.FolderName;
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.ReopenFromClosed(trackingId))
{ MessageBox.Show($"Case reopened!\n\nTrackingID: {trackingId}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else
MessageBox.Show("Error reopening case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
else MessageBox.Show("Error reopening case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
if (outlookHelper.CreateFolderAndMoveEmails(trackingId, folderName))
{ MessageBox.Show($"Rule created!\n\nFolder: {folderName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else
MessageBox.Show("Error creating rule. Make sure Outlook is open.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
else MessageBox.Show("Error creating rule. Make sure Outlook is open.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private void BtnRename_Click(object? sender, EventArgs e)
{
if (listView.SelectedItems.Count == 0)
{ MessageBox.Show("Please select a rule to rename.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo;
if (rule == null) return;
var inputForm = new Form
{
Text = "Rename Rule",
@@ -553,40 +588,33 @@ namespace OutlookCaseHelper
StartPosition = FormStartPosition.CenterScreen
};
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 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 btnCancel = new Button { Text = "Cancel", Left = 300, Top = 80, Width = 80, DialogResult = DialogResult.Cancel };
btnCancel.Click += (s, e) => inputForm.Close();
inputForm.Controls.AddRange(new Control[] { lbl, txt, btnOk, btnCancel });
inputForm.AcceptButton = btnOk; inputForm.CancelButton = btnCancel;
if (inputForm.ShowDialog() != DialogResult.OK) return;
string newName = txt.Text.Trim();
if (string.IsNullOrEmpty(newName) || newName == rule.FolderName) return;
if (outlookHelper.RenameRule(rule.FolderName, newName))
{ MessageBox.Show($"Rule renamed!\n\n{rule.FolderName} {newName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else
MessageBox.Show("Error renaming rule.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
{ MessageBox.Show($"Rule renamed!\n\n{rule.FolderName} -> {newName}", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else MessageBox.Show("Error renaming rule.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private void BtnClose_Click(object? sender, EventArgs e)
{
if (listView.SelectedItems.Count == 0)
{ MessageBox.Show("Please select a rule to close.", "Warning", MessageBoxButtons.OK, MessageBoxIcon.Warning); return; }
var rule = listView.SelectedItems[0].Tag as OutlookHelper.RuleInfo;
if (rule == null) return;
if (MessageBox.Show(
$"Are you sure you want to close this case?\n\nFolder: {rule.FolderName}\n\nThe folder will be moved to Inbox > Cases > 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;
if (outlookHelper.RemoveRuleAndMoveToClosed(rule.FolderName))
{ MessageBox.Show("Case closed!\n\nFolder moved to: Inbox > Cases > Closed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else
MessageBox.Show("Error closing case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
{ MessageBox.Show("Case closed!\n\nFolder moved to Closed.", "Success", MessageBoxButtons.OK, MessageBoxIcon.Information); LoadRules(); }
else MessageBox.Show("Error closing case.", "Error", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private void BtnReload_Click(object? sender, EventArgs e)
@@ -668,6 +696,7 @@ namespace OutlookCaseHelper
var btnOk = new Button { Text = "Save", Left = 216, Top = 200, Width = 80, DialogResult = DialogResult.OK };
var btnCancel = new Button { Text = "Cancel", Left = 304, Top = 200, Width = 80, DialogResult = DialogResult.Cancel };
btnCancel.Click += (s, e) => this.Close();
this.Controls.AddRange(new Control[] {
lblCreate, chkCreateAlt, chkCreateCtrl, chkCreateShift, cmbCreateKey,
@@ -720,7 +749,8 @@ namespace OutlookCaseHelper
uint rm = GetMod(chkRemoveAlt, chkRemoveCtrl, chkRemoveShift);
if (cm == 0 || rm == 0)
{
MessageBox.Show("Please select at least one modifier (Alt, Ctrl or Shift) for each shortcut.", "Validation", MessageBoxButtons.OK, MessageBoxIcon.Warning);
MessageBox.Show("Please select at least one modifier (Alt, Ctrl or Shift) for each shortcut.",
"Validation", MessageBoxButtons.OK, MessageBoxIcon.Warning);
e.Cancel = true; return;
}
CreateMod = cm; CreateKey = GetKeyCode(cmbCreateKey);
@@ -761,6 +791,7 @@ namespace OutlookCaseHelper
var btnOk = new Button { Text = "OK", Left = 220, Top = 158, Width = 80, DialogResult = DialogResult.OK };
var btnCancel = new Button { Text = "Cancel", Left = 310, Top = 158, Width = 80, DialogResult = DialogResult.Cancel };
btnCancel.Click += (s, e) => this.Close();
txtId.TextChanged += (s, e) => UpdatePreview(txtId.Text.Trim());
txtName.TextChanged += (s, e) => UpdatePreview(txtId.Text.Trim());
@@ -812,6 +843,7 @@ namespace OutlookCaseHelper
txtInput = new TextBox { Left = 20, Top = 45, Width = 330, Height = 24 };
var btnOk = new Button { Text = "OK", Left = 155, Top = 80, Width = 80, DialogResult = DialogResult.OK };
var btnCancel = new Button { Text = "Cancel", Left = 245, Top = 80, Width = 80, DialogResult = DialogResult.Cancel };
btnCancel.Click += (s, e) => this.Close();
this.Controls.AddRange(new Control[] { label, txtInput, btnOk, btnCancel });
this.AcceptButton = btnOk;
@@ -848,15 +880,15 @@ namespace OutlookCaseHelper
var lblDesc = new Label { Text = "Automatically organizes Outlook emails by TrackingID\ninto folders, keeping your inbox clean and cases managed.", Left = 30, Top = 178, Width = 330, Height = 40, TextAlign = ContentAlignment.MiddleCenter, Font = new Font("Segoe UI", 9) };
var separator = new Label { Left = 20, Top = 228, Width = 350, Height = 1, BorderStyle = BorderStyle.Fixed3D };
var lblCreatedBy = new Label { Text = "Created by Wellington Ribeiro", Left = 20, Top = 238, Width = 350, Height = 18, TextAlign = ContentAlignment.MiddleCenter, Font = new Font("Segoe UI", 9, FontStyle.Bold) };
var lblEmail = new LinkLabel { Text = "wribeiro@microsoft.com", Left = 20, Top = 258, Width = 350, Height = 18, TextAlign = ContentAlignment.MiddleCenter, Font = new Font("Segoe UI", 9) };
var lblSuggestions = new Label { Text = "For suggestions or bugs, contact the email above.", Left = 20, Top = 278, Width = 350, Height = 18, TextAlign = ContentAlignment.MiddleCenter, Font = new Font("Segoe UI", 8, FontStyle.Italic), ForeColor = Color.Gray };
var btnClose = new Button { Text = "Close", Left = 150, Top = 302, Width = 90, DialogResult = DialogResult.Cancel };
lblEmail.LinkClicked += (s, e) =>
{
try { System.Diagnostics.Process.Start(new System.Diagnostics.ProcessStartInfo { FileName = "mailto:wribeiro@microsoft.com?subject=Outlook Case Manager - Feedback", UseShellExecute = true }); } catch { }
};
var lblSuggestions = new Label { Text = "For suggestions or bugs, contact the email above.", Left = 20, Top = 278, Width = 350, Height = 18, TextAlign = ContentAlignment.MiddleCenter, Font = new Font("Segoe UI", 8, FontStyle.Italic), ForeColor = Color.Gray };
var btnClose = new Button { Text = "Close", Left = 150, Top = 302, Width = 90, DialogResult = DialogResult.Cancel };
btnClose.Click += (s, e) => this.Close();
this.Controls.AddRange(new Control[] { pictureBox, lblName, lblVersion, lblDate, lblDesc, separator, lblCreatedBy, lblEmail, lblSuggestions, btnClose });
this.CancelButton = btnClose;
+255 -64
View File
@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.IO;
using System.Text.Json;
@@ -8,7 +10,7 @@ using Outlook = Microsoft.Office.Interop.Outlook;
namespace OutlookCaseHelper
{
public class OutlookHelper
public class OutlookHelper : IDisposable
{
private Outlook.Application? outlookApp;
private Outlook.NameSpace? outlookNamespace;
@@ -16,6 +18,13 @@ namespace OutlookCaseHelper
private readonly string rulesFilePath;
private Outlook.Items? inboxItems;
private Outlook.Items? sentItems;
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
{
@@ -28,10 +37,51 @@ namespace OutlookCaseHelper
{
rulesFilePath = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"OutlookCaseHelper",
"active_rules.json");
"OutlookCaseHelper", "active_rules.json");
Directory.CreateDirectory(Path.GetDirectoryName(rulesFilePath)!);
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 ---
public void Dispose()
{
if (disposed) return;
disposed = true;
try
{
if (inboxItems != null)
{
inboxItems.ItemAdd -= OnEmailReceived;
Marshal.ReleaseComObject(inboxItems);
inboxItems = null;
}
if (sentItems != null)
{
sentItems.ItemAdd -= OnEmailSent;
Marshal.ReleaseComObject(sentItems);
sentItems = null;
}
_cachedActiveFolder = null;
_cachedClosedFolder = null;
if (outlookNamespace != null)
{
Marshal.ReleaseComObject(outlookNamespace);
outlookNamespace = null;
}
if (outlookApp != null)
{
Marshal.ReleaseComObject(outlookApp);
outlookApp = null;
}
}
catch { }
GC.SuppressFinalize(this);
}
// --- Outlook Connection ---
@@ -39,7 +89,6 @@ namespace OutlookCaseHelper
private bool EnsureOutlookConnected()
{
if (outlookApp != null && outlookNamespace != null) return true;
try
{
if (System.Diagnostics.Process.GetProcessesByName("OUTLOOK").Length == 0)
@@ -59,7 +108,7 @@ namespace OutlookCaseHelper
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (inbox != null)
{
@@ -78,26 +127,25 @@ namespace OutlookCaseHelper
// --- Email Events ---
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)
{
try
{
if (outlookNamespace == null) return;
if (activeRules.Count == 0) return;
if (item is not Outlook.MailItem mail) return;
if (mail.Subject == null) return;
if (outlookNamespace == null || activeRules.Count == 0) 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)
{
string trackingId = ExtractTrackingId(folderName);
if (!mail.Subject.Contains($"TrackingID#{trackingId}")) continue;
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return;
var target = GetOrCreateFolder(GetOrCreateFolder(GetOrCreateFolder(inbox, "Cases"), "Active"), folderName);
var target = GetOrCreateFolder(activeFolder, folderName);
mail.Move(target);
break;
}
@@ -119,10 +167,21 @@ namespace OutlookCaseHelper
public void ReloadRules()
{
activeRules.Clear();
// FIX: Invalidate folder cache when rules are reloaded
_cachedActiveFolder = null;
_cachedClosedFolder = null;
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()
{
@@ -141,8 +200,36 @@ namespace OutlookCaseHelper
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 ---
// 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()
{
if (!EnsureOutlookConnected()) return null;
@@ -168,9 +255,7 @@ namespace OutlookCaseHelper
try
{
if (!EnsureOutlookConnected()) return false;
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var closed = GetFolder(GetFolder(inbox, "Cases"), "Closed");
var closed = GetCachedClosedFolder();
return closed != null && GetFolderStartingWith(closed, trackingId) != null;
}
catch { return false; }
@@ -181,23 +266,37 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false;
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null)
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
}
var target = GetOrCreateFolder(GetOrCreateFolder(GetOrCreateFolder(inbox, "Cases"), "Active"), 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}%'";
MoveFilteredEmails(inbox, filter, target);
var inbox2 = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (sent != null) MoveFilteredEmails(sent, filter, target);
if (inbox2 != null) MoveFilteredEmails(inbox2, filter, target);
if (sent != null) MoveFilteredEmails(sent, filter, target);
foreach (Outlook.Folder folder in inbox.Parent.Folders)
if (inbox2 != null)
{
if (folder.Name != "Cases" &&
folder.EntryID != inbox.EntryID &&
folder.EntryID != sent?.EntryID)
try { MoveFilteredEmails(folder, filter, target); } catch { }
foreach (Outlook.Folder folder in inbox2.Parent.Folders)
{
if (folder.Name != "Cases" &&
folder.EntryID != inbox2.EntryID &&
folder.EntryID != sent?.EntryID)
try { MoveFilteredEmails(folder, filter, target); } catch { }
}
}
activeRules.Add(folderName);
@@ -216,15 +315,23 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false;
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return false;
var cases = GetFolder(inbox, "Cases");
var active = GetFolder(cases, "Active");
var folder = GetFolder(active, folderName);
var folder = GetFolder(activeFolder, folderName);
if (folder == null) return false;
folder.MoveTo(GetOrCreateFolder(cases!, "Closed"));
var closedFolder = GetCachedClosedFolder();
if (closedFolder == null)
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases");
closedFolder = GetOrCreateFolder(cases, "Closed");
_cachedClosedFolder = closedFolder;
}
folder.MoveTo(closedFolder);
activeRules.Remove(folderName);
SaveRules();
return true;
@@ -237,9 +344,8 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false;
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var active = GetFolder(GetFolder(inbox, "Cases"), "Active");
var folder = GetFolder(active, oldFolderName);
var activeFolder = GetCachedActiveFolder();
var folder = GetFolder(activeFolder, oldFolderName);
if (folder == null) return false;
folder.Name = newFolderName;
@@ -256,20 +362,25 @@ namespace OutlookCaseHelper
if (!EnsureOutlookConnected()) return false;
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var closedFolder = GetCachedClosedFolder();
if (closedFolder == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases");
var active = GetOrCreateFolder(cases, "Active");
var closed = GetFolder(cases, "Closed");
if (closed == null) return false;
var closedTracking = GetFolderStartingWith(closed, trackingId);
var closedTracking = GetFolderStartingWith(closedFolder, trackingId);
if (closedTracking == null) return false;
string existingName = closedTracking.Name;
var existingActive = GetFolder(active, existingName);
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null)
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return false;
var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
}
var existingActive = GetFolder(activeFolder, existingName);
if (existingActive != null)
{
var toMove = new List<Outlook.MailItem>();
@@ -280,12 +391,12 @@ namespace OutlookCaseHelper
}
else
{
closedTracking.MoveTo(active);
closedTracking.MoveTo(activeFolder);
}
try
{
var target = GetFolder(active, existingName);
var target = GetFolder(activeFolder, existingName);
if (triggerEmail != null && target != null)
triggerEmail.Move(target);
}
@@ -299,9 +410,8 @@ namespace OutlookCaseHelper
{
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var active = GetFolder(GetFolder(inbox, "Cases"), "Active");
var moved = GetFolderStartingWith(active!, trackingId);
var active = GetCachedActiveFolder();
var moved = GetFolderStartingWith(active!, trackingId);
if (moved != null) { activeRules.Add(moved.Name); SaveRules(); return true; }
}
catch { }
@@ -317,16 +427,23 @@ namespace OutlookCaseHelper
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
var sent = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderSentMail);
if (inbox == null) return;
var active = GetOrCreateFolder(GetOrCreateFolder(inbox, "Cases"), "Active");
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null)
{
var cases = GetOrCreateFolder(inbox, "Cases");
activeFolder = GetOrCreateFolder(cases, "Active");
_cachedActiveFolder = activeFolder;
}
foreach (var folderName in activeRules)
{
string trackingId = ExtractTrackingId(folderName);
// FIX: Correct DASL filter syntax (plain double quotes)
string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'";
var target = GetOrCreateFolder(active, folderName);
var target = GetOrCreateFolder(activeFolder, folderName);
MoveFilteredEmails(inbox, filter, target);
if (sent != null) MoveFilteredEmails(sent, filter, target);
@@ -343,18 +460,25 @@ namespace OutlookCaseHelper
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox == null) return;
var cases = GetOrCreateFolder(inbox, "Cases");
var active = GetOrCreateFolder(cases, "Active");
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null)
{
var cases = GetOrCreateFolder(inbox, "Cases");
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>();
if (inbox.Parent is Outlook.Folder root)
GetAllFolders(root, allFolders, cases.EntryID);
GetAllFolders(root, allFolders, activeFolder.EntryID);
foreach (var folderName in activeRules)
{
string trackingId = ExtractTrackingId(folderName);
// FIX: Correct DASL filter syntax (plain double quotes)
string filter = $"@SQL=\"urn:schemas:httpmail:subject\" LIKE '%TrackingID#{trackingId}%'";
var target = GetOrCreateFolder(active, folderName);
var target = GetOrCreateFolder(activeFolder, folderName);
foreach (var folder in allFolders)
try { MoveFilteredEmails(folder, filter, target); } catch { }
@@ -363,19 +487,20 @@ namespace OutlookCaseHelper
catch { }
}
// PERF FIX: Resolve activeFolder once before the loop instead of inside it.
// Original called EnsureOutlookConnected + FindFolderByNameAnywhere per iteration.
public List<RuleInfo> GetActiveRulesInfo()
{
var result = new List<RuleInfo>();
if (!EnsureOutlookConnected()) return result;
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
var active = GetFolder(GetFolder(inbox, "Cases"), "Active");
if (active == null) return result;
var activeFolder = GetCachedActiveFolder();
if (activeFolder == null) return result;
foreach (var folderName in activeRules)
{
var folder = GetFolder(active, folderName);
var folder = GetFolder(activeFolder, folderName);
result.Add(new RuleInfo
{
FolderName = folderName,
@@ -388,24 +513,90 @@ namespace OutlookCaseHelper
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) ---
private Outlook.Folder? FindFolderByNameAnywhere(string name)
{
try
{
var inbox = GetDefaultFolder(Outlook.OlDefaultFolders.olFolderInbox);
if (inbox?.Parent is not Outlook.Folder root) return null;
return SearchFolderRecursive(root, name);
}
catch { return null; }
}
private Outlook.Folder? SearchFolderRecursive(Outlook.Folder parent, string name)
{
foreach (Outlook.Folder folder in parent.Folders)
{
try
{
if (folder.Name == name) return folder;
var found = SearchFolderRecursive(folder, name);
if (found != null) return found;
}
catch { }
}
return null;
}
// --- 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)
{
var match = Regex.Match(folderName, @"^(\d+)");
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)
{
Outlook.Items? restricted = null;
try
{
restricted = source.Items.Restrict(filter);
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);
foreach (var mail in toMove) mail.Move(dest);
}
catch { }
finally
{
if (restricted != null)
Marshal.ReleaseComObject(restricted);
}
}
private void GetAllFolders(Outlook.Folder parent, List<Outlook.Folder> result, string excludeEntryId)