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 <noreply@anthropic.com>
This commit is contained in:
aj
2026-08-06 23:15:02 -04:00
co-authored by Claude Opus 4.6
parent ece3b0d771
commit 6e5b756de7
2 changed files with 180 additions and 46 deletions
+2
View File
@@ -2,6 +2,7 @@ using NetOffice.OutlookApi;
public class EmailResult public class EmailResult
{ {
public string EntryId { get; set; } = "";
public string Subject { get; set; } = ""; public string Subject { get; set; } = "";
public string Sender { get; set; } = ""; public string Sender { get; set; } = "";
public string CC { get; set; } = ""; public string CC { get; set; } = "";
@@ -22,6 +23,7 @@ public class EmailResult
return new EmailResult return new EmailResult
{ {
EntryId = mail.EntryID ?? "",
Subject = mail.Subject ?? "(No Subject)", Subject = mail.Subject ?? "(No Subject)",
Sender = $"{mail.SenderName} <{mail.SenderEmailAddress}>", Sender = $"{mail.SenderName} <{mail.SenderEmailAddress}>",
CC = mail.CC ?? "", CC = mail.CC ?? "",
+161 -29
View File
@@ -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( public static string MoveToJunk(
[Description("Exact or partial subject line to match")] string subject, [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null,
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, [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("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) [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 try
{
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
var junkFolder = ns.GetDefaultFolder(OlDefaultFolders.olFolderJunk);
MailItem? mail = null;
if (!string.IsNullOrEmpty(entryId))
{
mail = FindEmailByEntryId(ns, entryId);
}
else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date))
{ {
if (!TryParseDate(date, out var targetDate)) if (!TryParseDate(date, out var targetDate))
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
TimeSpan? targetTime = TryParseTime(time); TimeSpan? targetTime = TryParseTime(time);
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
var foldersToSearch = GetFoldersToSearch(ns, folder); var foldersToSearch = GetFoldersToSearch(ns, folder);
var junkFolder = ns.GetDefaultFolder(OlDefaultFolders.olFolderJunk);
foreach (var mailFolder in foldersToSearch) foreach (var mailFolder in foldersToSearch)
{ {
var mail = FindEmail(mailFolder, subject, targetDate, targetTime); mail = FindEmail(mailFolder, subject, targetDate, targetTime);
if (mail != null) break;
}
}
else
{
return "Please provide either entryId, or both subject and date.";
}
if (mail != null) if (mail != null)
{ {
var emailSubject = mail.Subject; var emailSubject = mail.Subject;
mail.Move(junkFolder); mail.Move(junkFolder);
return $"Successfully moved email '{emailSubject}' to Junk folder."; return $"Successfully moved email '{emailSubject}' to Junk folder.";
} }
}
return "Email not found with the specified subject and date."; return "Email not found with the specified criteria.";
} }
catch (System.Exception ex) 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<string>();
var failed = new List<string>();
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( public static string ReadEmail(
[Description("Exact or partial subject line to match")] string subject, [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null,
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, [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("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) [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 try
{
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
MailItem? mail = null;
if (!string.IsNullOrEmpty(entryId))
{
mail = FindEmailByEntryId(ns, entryId);
}
else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date))
{ {
if (!TryParseDate(date, out var targetDate)) if (!TryParseDate(date, out var targetDate))
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
TimeSpan? targetTime = TryParseTime(time); TimeSpan? targetTime = TryParseTime(time);
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
var foldersToSearch = GetFoldersToSearch(ns, folder); var foldersToSearch = GetFoldersToSearch(ns, folder);
foreach (var mailFolder in foldersToSearch) foreach (var mailFolder in foldersToSearch)
{ {
var mail = FindEmail(mailFolder, subject, targetDate, targetTime); mail = FindEmail(mailFolder, subject, targetDate, targetTime);
if (mail != null) if (mail != null) break;
return FormatFullEmail(mail); }
}
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) 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( public static string AnalyzeSpam(
[Description("Exact or partial subject line to match")] string subject, [Description("Entry ID of the email (from SearchEmails/ScanForSpam results). Most reliable way to identify an email.")] string? entryId = null,
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date, [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("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) [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 try
{
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
MailItem? mail = null;
if (!string.IsNullOrEmpty(entryId))
{
mail = FindEmailByEntryId(ns, entryId);
}
else if (!string.IsNullOrEmpty(subject) && !string.IsNullOrEmpty(date))
{ {
if (!TryParseDate(date, out var targetDate)) if (!TryParseDate(date, out var targetDate))
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy"; return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
TimeSpan? targetTime = TryParseTime(time); TimeSpan? targetTime = TryParseTime(time);
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
var foldersToSearch = GetFoldersToSearch(ns, folder); var foldersToSearch = GetFoldersToSearch(ns, folder);
foreach (var mailFolder in foldersToSearch) foreach (var mailFolder in foldersToSearch)
{ {
var mail = FindEmail(mailFolder, subject, targetDate, targetTime); mail = FindEmail(mailFolder, subject, targetDate, targetTime);
if (mail != null) break;
}
}
else
{
return "Please provide either entryId, or both subject and date.";
}
if (mail != null) if (mail != null)
{ {
var detector = new SpamDetector(); var detector = new SpamDetector();
var result = detector.Analyze(mail); var result = detector.Analyze(mail);
return FormatSpamAnalysis(mail, result); return FormatSpamAnalysis(mail, result);
} }
}
return "Email not found with the specified subject and date."; return "Email not found with the specified criteria.";
} }
catch (System.Exception ex) catch (System.Exception ex)
{ {
@@ -318,6 +435,7 @@ public class EmailSearchTools
var emptyBar = new string('-', 10 - scoreBar.Length); var emptyBar = new string('-', 10 - scoreBar.Length);
output.AppendLine($"[{scoreBar}{emptyBar}] {spam.FinalScore:P0} - {spam.SpamLikelihood}"); output.AppendLine($"[{scoreBar}{emptyBar}] {spam.FinalScore:P0} - {spam.SpamLikelihood}");
output.AppendLine($" ID: {email.EntryId}");
output.AppendLine($" Subject: {email.Subject}"); output.AppendLine($" Subject: {email.Subject}");
output.AppendLine($" From: {email.Sender}"); output.AppendLine($" From: {email.Sender}");
output.AppendLine($" Date: {email.ReceivedDate:yyyy-MM-dd HH:mm}"); 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) private static MailItem? FindEmail(MAPIFolder folder, string subject, DateTime targetDate, TimeSpan? targetTime = null)
{ {
var items = folder.Items; var items = folder.Items;
@@ -616,6 +747,7 @@ public class EmailSearchTools
{ {
var email = results[i]; var email = results[i];
output.AppendLine($"--- Email {offset + i + 1} ---"); output.AppendLine($"--- Email {offset + i + 1} ---");
output.AppendLine($"ID: {email.EntryId}");
output.AppendLine($"Subject: {email.Subject}"); output.AppendLine($"Subject: {email.Subject}");
output.AppendLine($"From: {email.Sender}"); output.AppendLine($"From: {email.Sender}");