From 6e5b756de727b1ffafeeae548adf52f9630cd8c8 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 6 Aug 2026 23:15:02 -0400 Subject: [PATCH] feat: use entryId for reliable email lookup, add batch junk move Subject+date matching was fragile when emails shared subjects or timestamps were ambiguous. Expose Outlook's EntryID through search/scan results so MoveToJunk, ReadEmail, and AnalyzeSpam can target an exact email, with subject+date retained as a fallback. Also adds BatchMoveToJunk to move many spam emails in one call instead of round-tripping per email. Co-Authored-By: Claude Opus 4.6 --- EmailSearch/EmailResult.cs | 2 + EmailSearch/EmailSearchTools.cs | 224 +++++++++++++++++++++++++------- 2 files changed, 180 insertions(+), 46 deletions(-) diff --git a/EmailSearch/EmailResult.cs b/EmailSearch/EmailResult.cs index a40dc88..22bde84 100644 --- a/EmailSearch/EmailResult.cs +++ b/EmailSearch/EmailResult.cs @@ -2,6 +2,7 @@ using NetOffice.OutlookApi; public class EmailResult { + public string EntryId { get; set; } = ""; public string Subject { get; set; } = ""; public string Sender { get; set; } = ""; public string CC { get; set; } = ""; @@ -22,6 +23,7 @@ public class EmailResult return new EmailResult { + EntryId = mail.EntryID ?? "", Subject = mail.Subject ?? "(No Subject)", Sender = $"{mail.SenderName} <{mail.SenderEmailAddress}>", CC = mail.CC ?? "", diff --git a/EmailSearch/EmailSearchTools.cs b/EmailSearch/EmailSearchTools.cs index f879424..74ff62a 100644 --- a/EmailSearch/EmailSearchTools.cs +++ b/EmailSearch/EmailSearchTools.cs @@ -76,37 +76,53 @@ public class EmailSearchTools } } - [McpServerTool, Description("Move an email to the Junk folder by subject and date. Use after SearchEmails to identify the email to move.")] + [McpServerTool, Description("Move an email to the Junk folder. Prefer using entryId (from SearchEmails/ScanForSpam results) for reliable matching. Falls back to subject+date matching.")] public static string MoveToJunk( - [Description("Exact or partial subject line to match")] string subject, - [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, + [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null, + [Description("Exact or partial subject line to match (used when entryId is not provided)")] string? subject = null, + [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy) (used when entryId is not provided)")] string? date = null, [Description("Outlook folder to search for the email: Inbox, SentMail, Drafts, All, or any custom folder name (default Inbox)")] string folder = "Inbox", [Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null) { try { - if (!TryParseDate(date, out var targetDate)) - return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; - - TimeSpan? targetTime = TryParseTime(time); - using var outlookApp = new OutlookApp(); var ns = outlookApp.GetNamespace("MAPI"); - var foldersToSearch = GetFoldersToSearch(ns, folder); var junkFolder = ns.GetDefaultFolder(OlDefaultFolders.olFolderJunk); - foreach (var mailFolder in foldersToSearch) + MailItem? mail = null; + + if (!string.IsNullOrEmpty(entryId)) { - var mail = FindEmail(mailFolder, subject, targetDate, targetTime); - if (mail != null) + mail = FindEmailByEntryId(ns, entryId); + } + else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date)) + { + if (!TryParseDate(date, out var targetDate)) + return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; + + TimeSpan? targetTime = TryParseTime(time); + var foldersToSearch = GetFoldersToSearch(ns, folder); + + foreach (var mailFolder in foldersToSearch) { - var emailSubject = mail.Subject; - mail.Move(junkFolder); - return $"Successfully moved email '{emailSubject}' to Junk folder."; + mail = FindEmail(mailFolder, subject, targetDate, targetTime); + if (mail != null) break; } } + else + { + return "Please provide either entryId, or both subject and date."; + } - return "Email not found with the specified subject and date."; + if (mail != null) + { + var emailSubject = mail.Subject; + mail.Move(junkFolder); + return $"Successfully moved email '{emailSubject}' to Junk folder."; + } + + return "Email not found with the specified criteria."; } catch (System.Exception ex) { @@ -114,32 +130,117 @@ public class EmailSearchTools } } - [McpServerTool, Description("Read the full body of a specific email by subject and date. Use after SearchEmails to get complete email content.")] + [McpServerTool, Description("Move multiple emails to the Junk folder in a single operation. Pass entry IDs from SearchEmails or ScanForSpam results. Much faster than calling MoveToJunk repeatedly.")] + public static string BatchMoveToJunk( + [Description("Comma-separated entry IDs of emails to move to Junk")] string entryIds) + { + if (string.IsNullOrWhiteSpace(entryIds)) + return "No entry IDs provided."; + + var ids = entryIds.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + if (ids.Length == 0) + return "No valid entry IDs provided."; + + try + { + using var outlookApp = new OutlookApp(); + var ns = outlookApp.GetNamespace("MAPI"); + var junkFolder = ns.GetDefaultFolder(OlDefaultFolders.olFolderJunk); + + var moved = new List(); + var failed = new List(); + + foreach (var id in ids) + { + try + { + var mail = FindEmailByEntryId(ns, id); + if (mail != null) + { + var subject = mail.Subject ?? "(No Subject)"; + mail.Move(junkFolder); + moved.Add(subject); + } + else + { + failed.Add($"{id[..Math.Min(id.Length, 12)]}... (not found)"); + } + } + catch (System.Exception ex) + { + failed.Add($"{id[..Math.Min(id.Length, 12)]}... ({ex.Message})"); + } + } + + var output = new StringBuilder(); + output.AppendLine($"Batch move complete: {moved.Count} moved, {failed.Count} failed."); + + if (moved.Count > 0) + { + output.AppendLine(); + output.AppendLine("Moved to Junk:"); + foreach (var subject in moved) + output.AppendLine($" - {subject}"); + } + + if (failed.Count > 0) + { + output.AppendLine(); + output.AppendLine("Failed:"); + foreach (var error in failed) + output.AppendLine($" - {error}"); + } + + return output.ToString(); + } + catch (System.Exception ex) + { + return $"Error during batch move: {ex.Message}"; + } + } + + [McpServerTool, Description("Read the full body of a specific email. Prefer using entryId (from SearchEmails/ScanForSpam results) for reliable matching. Falls back to subject+date matching.")] public static string ReadEmail( - [Description("Exact or partial subject line to match")] string subject, - [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, + [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null, + [Description("Exact or partial subject line to match (used when entryId is not provided)")] string? subject = null, + [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy) (used when entryId is not provided)")] string? date = null, [Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All", [Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null) { try { - if (!TryParseDate(date, out var targetDate)) - return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; - - TimeSpan? targetTime = TryParseTime(time); - using var outlookApp = new OutlookApp(); var ns = outlookApp.GetNamespace("MAPI"); - var foldersToSearch = GetFoldersToSearch(ns, folder); - foreach (var mailFolder in foldersToSearch) + MailItem? mail = null; + + if (!string.IsNullOrEmpty(entryId)) { - var mail = FindEmail(mailFolder, subject, targetDate, targetTime); - if (mail != null) - return FormatFullEmail(mail); + mail = FindEmailByEntryId(ns, entryId); + } + else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date)) + { + if (!TryParseDate(date, out var targetDate)) + return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; + + TimeSpan? targetTime = TryParseTime(time); + var foldersToSearch = GetFoldersToSearch(ns, folder); + + foreach (var mailFolder in foldersToSearch) + { + mail = FindEmail(mailFolder, subject, targetDate, targetTime); + if (mail != null) break; + } + } + else + { + return "Please provide either entryId, or both subject and date."; } - return "Email not found with the specified subject and date."; + if (mail != null) + return FormatFullEmail(mail); + + return "Email not found with the specified criteria."; } catch (System.Exception ex) { @@ -147,36 +248,52 @@ public class EmailSearchTools } } - [McpServerTool, Description("Analyze a specific email for spam indicators. Returns spam score (0.0-1.0), spam likelihood, and detected red flags.")] + [McpServerTool, Description("Analyze a specific email for spam indicators. Prefer using entryId (from SearchEmails/ScanForSpam results) for reliable matching. Returns spam score (0.0-1.0), spam likelihood, and detected red flags.")] public static string AnalyzeSpam( - [Description("Exact or partial subject line to match")] string subject, - [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, + [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null, + [Description("Exact or partial subject line to match (used when entryId is not provided)")] string? subject = null, + [Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy) (used when entryId is not provided)")] string? date = null, [Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All", [Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null) { try { - if (!TryParseDate(date, out var targetDate)) - return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; - - TimeSpan? targetTime = TryParseTime(time); - using var outlookApp = new OutlookApp(); var ns = outlookApp.GetNamespace("MAPI"); - var foldersToSearch = GetFoldersToSearch(ns, folder); - foreach (var mailFolder in foldersToSearch) + MailItem? mail = null; + + if (!string.IsNullOrEmpty(entryId)) { - var mail = FindEmail(mailFolder, subject, targetDate, targetTime); - if (mail != null) + mail = FindEmailByEntryId(ns, entryId); + } + else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date)) + { + if (!TryParseDate(date, out var targetDate)) + return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; + + TimeSpan? targetTime = TryParseTime(time); + var foldersToSearch = GetFoldersToSearch(ns, folder); + + foreach (var mailFolder in foldersToSearch) { - var detector = new SpamDetector(); - var result = detector.Analyze(mail); - return FormatSpamAnalysis(mail, result); + mail = FindEmail(mailFolder, subject, targetDate, targetTime); + if (mail != null) break; } } + else + { + return "Please provide either entryId, or both subject and date."; + } - return "Email not found with the specified subject and date."; + if (mail != null) + { + var detector = new SpamDetector(); + var result = detector.Analyze(mail); + return FormatSpamAnalysis(mail, result); + } + + return "Email not found with the specified criteria."; } catch (System.Exception ex) { @@ -318,6 +435,7 @@ public class EmailSearchTools var emptyBar = new string('-', 10 - scoreBar.Length); output.AppendLine($"[{scoreBar}{emptyBar}] {spam.FinalScore:P0} - {spam.SpamLikelihood}"); + output.AppendLine($" ID: {email.EntryId}"); output.AppendLine($" Subject: {email.Subject}"); output.AppendLine($" From: {email.Sender}"); output.AppendLine($" Date: {email.ReceivedDate:yyyy-MM-dd HH:mm}"); @@ -445,6 +563,19 @@ public class EmailSearchTools } } + private static MailItem? FindEmailByEntryId(_NameSpace ns, string entryId) + { + try + { + var item = ns.GetItemFromID(entryId); + return item as MailItem; + } + catch + { + return null; + } + } + private static MailItem? FindEmail(MAPIFolder folder, string subject, DateTime targetDate, TimeSpan? targetTime = null) { var items = folder.Items; @@ -616,6 +747,7 @@ public class EmailSearchTools { var email = results[i]; output.AppendLine($"--- Email {offset + i + 1} ---"); + output.AppendLine($"ID: {email.EntryId}"); output.AppendLine($"Subject: {email.Subject}"); output.AppendLine($"From: {email.Sender}");