Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c680bb5571 | |||
| 9be729101b |
+96
-64
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user