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>
870 lines
34 KiB
C#
870 lines
34 KiB
C#
using ModelContextProtocol.Server;
|
|
using System.ComponentModel;
|
|
using System.Globalization;
|
|
using System.Text;
|
|
using OutlookApp = NetOffice.OutlookApi.Application;
|
|
using NetOffice.OutlookApi;
|
|
using NetOffice.OutlookApi.Enums;
|
|
using EmailSearch.SpamDetection;
|
|
|
|
[McpServerToolType]
|
|
public class EmailSearchTools
|
|
{
|
|
private static readonly string[] SupportedDateFormats =
|
|
[
|
|
"yyyy-MM-dd",
|
|
"MM/dd/yyyy",
|
|
"dd/MM/yyyy",
|
|
"yyyy/MM/dd",
|
|
"MM-dd-yyyy",
|
|
"dd-MM-yyyy"
|
|
];
|
|
|
|
[McpServerTool, Description("Search emails in Outlook by keywords, sender, subject, or date range. Returns matching emails with subject, sender, date, and body preview.")]
|
|
public static string SearchEmails(
|
|
[Description("Keywords to search for in email subject and body")] string? keywords = null,
|
|
[Description("Filter by sender email or name")] string? sender = null,
|
|
[Description("Filter by subject contains")] string? subject = null,
|
|
[Description("Number of days back to search (default 365)")] int daysBack = 365,
|
|
[Description("Maximum number of results to return (default 25)")] int maxResults = 25,
|
|
[Description("Number of results to skip for pagination (default 0)")] int offset = 0,
|
|
[Description("Outlook folder to search: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All",
|
|
[Description("Filter by attachment: 'true' for emails with attachments, 'false' for without, or filename to search")] string? hasAttachment = null,
|
|
[Description("Filter by importance: High, Normal, or Low")] string? importance = null,
|
|
[Description("Filter by category name")] string? category = null,
|
|
[Description("Filter by flag status: Flagged, Completed, or NotFlagged")] string? flagStatus = null)
|
|
{
|
|
try
|
|
{
|
|
using var outlookApp = new OutlookApp();
|
|
var ns = outlookApp.GetNamespace("MAPI");
|
|
var foldersToSearch = GetFoldersToSearch(ns, folder);
|
|
var cutoffDate = DateTime.Now.AddDays(-daysBack);
|
|
|
|
var filters = new SearchFilters
|
|
{
|
|
Keywords = keywords,
|
|
Sender = sender,
|
|
Subject = subject,
|
|
HasAttachment = hasAttachment,
|
|
Importance = ParseImportance(importance),
|
|
Category = category,
|
|
FlagStatus = ParseFlagStatus(flagStatus)
|
|
};
|
|
|
|
var allResults = new List<EmailResult>();
|
|
foreach (var mailFolder in foldersToSearch)
|
|
{
|
|
SearchFolder(mailFolder, filters, cutoffDate, maxResults + offset, allResults);
|
|
if (allResults.Count >= maxResults + offset)
|
|
break;
|
|
}
|
|
|
|
// Apply pagination
|
|
var pagedResults = allResults.Skip(offset).Take(maxResults).ToList();
|
|
|
|
if (pagedResults.Count == 0)
|
|
return offset > 0
|
|
? $"No more emails found. Showing results {offset + 1}+ of {allResults.Count} total."
|
|
: "No emails found matching the search criteria.";
|
|
|
|
return FormatSearchResults(pagedResults, offset, allResults.Count);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
return $"Error searching emails: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[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("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
|
|
{
|
|
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))
|
|
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.";
|
|
}
|
|
|
|
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)
|
|
{
|
|
return $"Error moving email to junk: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[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(
|
|
[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
|
|
{
|
|
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))
|
|
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.";
|
|
}
|
|
|
|
if (mail != null)
|
|
return FormatFullEmail(mail);
|
|
|
|
return "Email not found with the specified criteria.";
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
return $"Error reading email: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[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("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
|
|
{
|
|
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))
|
|
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.";
|
|
}
|
|
|
|
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)
|
|
{
|
|
return $"Error analyzing email: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
[McpServerTool, Description("Scan recent emails for spam and return a summary with spam scores. Useful for identifying potential spam in your inbox.")]
|
|
public static string ScanForSpam(
|
|
[Description("Number of days back to scan (default 7)")] int daysBack = 7,
|
|
[Description("Maximum number of emails to scan (default 50)")] int maxEmails = 50,
|
|
[Description("Minimum spam score to include in results (0.0-1.0, default 0.3)")] double minScore = 0.3,
|
|
[Description("Outlook folder to scan: Inbox, SentMail, Drafts, All, or custom folder name (default Inbox)")] string folder = "Inbox")
|
|
{
|
|
try
|
|
{
|
|
using var outlookApp = new OutlookApp();
|
|
var ns = outlookApp.GetNamespace("MAPI");
|
|
var foldersToSearch = GetFoldersToSearch(ns, folder);
|
|
var cutoffDate = DateTime.Now.AddDays(-daysBack);
|
|
var detector = new SpamDetector();
|
|
|
|
var results = new List<(EmailResult email, SpamAnalysisResult spam)>();
|
|
|
|
foreach (var mailFolder in foldersToSearch)
|
|
{
|
|
try
|
|
{
|
|
var items = mailFolder.Items;
|
|
items.Sort("[ReceivedTime]", true);
|
|
|
|
var filter = $"[ReceivedTime] >= '{cutoffDate:MM/dd/yyyy}'";
|
|
var filteredItems = items.Restrict(filter);
|
|
|
|
foreach (var item in filteredItems)
|
|
{
|
|
if (results.Count >= maxEmails)
|
|
break;
|
|
|
|
if (item is MailItem mail)
|
|
{
|
|
var spamResult = detector.Analyze(mail);
|
|
if (spamResult.FinalScore >= minScore)
|
|
{
|
|
var emailResult = EmailResult.FromMailItem(mail, mailFolder.Name);
|
|
results.Add((emailResult, spamResult));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
catch { }
|
|
|
|
if (results.Count >= maxEmails)
|
|
break;
|
|
}
|
|
|
|
if (results.Count == 0)
|
|
return $"No emails with spam score >= {minScore:P0} found in the last {daysBack} days.";
|
|
|
|
// Sort by spam score descending
|
|
results = results.OrderByDescending(r => r.spam.FinalScore).ToList();
|
|
|
|
return FormatSpamScanResults(results, daysBack, minScore);
|
|
}
|
|
catch (System.Exception ex)
|
|
{
|
|
return $"Error scanning for spam: {ex.Message}";
|
|
}
|
|
}
|
|
|
|
private static string FormatSpamAnalysis(MailItem mail, SpamAnalysisResult result)
|
|
{
|
|
var output = new StringBuilder();
|
|
output.AppendLine("=== SPAM ANALYSIS REPORT ===");
|
|
output.AppendLine();
|
|
output.AppendLine($"Subject: {mail.Subject}");
|
|
output.AppendLine($"From: {mail.SenderName} <{mail.SenderEmailAddress}>");
|
|
output.AppendLine($"Date: {mail.ReceivedTime:yyyy-MM-dd HH:mm}");
|
|
output.AppendLine();
|
|
output.AppendLine("--- SPAM SCORE ---");
|
|
output.AppendLine($"Score: {result.FinalScore:P0}");
|
|
output.AppendLine($"Likelihood: {result.SpamLikelihood}");
|
|
output.AppendLine($"Predicted Spam: {(result.PredictedSpam ? "YES" : "No")}");
|
|
output.AppendLine();
|
|
|
|
if (result.RedFlags.Count > 0)
|
|
{
|
|
output.AppendLine("--- RED FLAGS DETECTED ---");
|
|
foreach (var flag in result.RedFlags)
|
|
{
|
|
output.AppendLine($" - {flag}");
|
|
}
|
|
output.AppendLine();
|
|
}
|
|
|
|
if (result.Features != null)
|
|
{
|
|
output.AppendLine("--- SENDER ANALYSIS ---");
|
|
output.AppendLine($"Display Name: {result.Features.DisplayName}");
|
|
output.AppendLine($"Email Address: {result.Features.FromAddress}");
|
|
output.AppendLine($"Domain: {result.Features.FromDomain}");
|
|
output.AppendLine($"Free Email Provider: {(result.Features.FreeMailboxDomain ? "Yes" : "No")}");
|
|
output.AppendLine($"Known/Trusted Domain: {(!result.Features.UnknownDomain ? "Yes" : "No")}");
|
|
output.AppendLine($"Blocklisted: {(result.Features.IsBlocklisted ? "YES" : "No")}");
|
|
output.AppendLine();
|
|
|
|
output.AppendLine("--- AUTHENTICATION ---");
|
|
output.AppendLine($"SPF Failed: {(result.Features.SpfFail ? "YES" : "No")}");
|
|
output.AppendLine($"DKIM Failed: {(result.Features.DkimFail ? "YES" : "No")}");
|
|
output.AppendLine($"DMARC Failed: {(result.Features.DmarcFail ? "YES" : "No")}");
|
|
output.AppendLine($"Reply-To Mismatch: {(result.Features.ReplyToDomainMismatch ? "YES" : "No")}");
|
|
output.AppendLine();
|
|
|
|
output.AppendLine("--- CONTENT ANALYSIS ---");
|
|
output.AppendLine($"URLs Found: {result.Features.UrlCount}");
|
|
output.AppendLine($"Uses URL Shortener: {(result.Features.UsesShortener ? "YES" : "No")}");
|
|
output.AppendLine($"IP-based URL: {(result.Features.HasIpLink ? "YES" : "No")}");
|
|
output.AppendLine($"Suspicious TLD: {(result.Features.SuspiciousTld ? "YES" : "No")}");
|
|
output.AppendLine($"Has Attachments: {(result.Features.HasAttachment ? "Yes" : "No")}");
|
|
if (result.Features.HasAttachment)
|
|
output.AppendLine($"Attachment Risk: {result.Features.AttachmentRiskScore:P0}");
|
|
output.AppendLine($"Keyword Bait: {(result.Features.KeywordBait ? "YES" : "No")}");
|
|
output.AppendLine($"Has Tracking Pixel: {(result.Features.HasTrackingPixel ? "Yes" : "No")}");
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
private static string FormatSpamScanResults(List<(EmailResult email, SpamAnalysisResult spam)> results, int daysBack, double minScore)
|
|
{
|
|
var output = new StringBuilder();
|
|
output.AppendLine($"=== SPAM SCAN RESULTS ===");
|
|
output.AppendLine($"Scanned last {daysBack} days, showing {results.Count} email(s) with spam score >= {minScore:P0}");
|
|
output.AppendLine();
|
|
|
|
foreach (var (email, spam) in results)
|
|
{
|
|
var scoreBar = new string('#', (int)(spam.FinalScore * 10));
|
|
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}");
|
|
output.AppendLine($" Folder: {email.Folder}");
|
|
|
|
if (spam.RedFlags.Count > 0)
|
|
{
|
|
var topFlags = spam.RedFlags.Take(3);
|
|
output.AppendLine($" Flags: {string.Join("; ", topFlags)}");
|
|
}
|
|
output.AppendLine();
|
|
}
|
|
|
|
var highSpam = results.Count(r => r.spam.FinalScore >= 0.7);
|
|
var mediumSpam = results.Count(r => r.spam.FinalScore >= 0.5 && r.spam.FinalScore < 0.7);
|
|
|
|
output.AppendLine("--- SUMMARY ---");
|
|
output.AppendLine($"High likelihood spam (>=70%): {highSpam}");
|
|
output.AppendLine($"Medium likelihood spam (50-69%): {mediumSpam}");
|
|
output.AppendLine($"Lower likelihood ({minScore:P0}-49%): {results.Count - highSpam - mediumSpam}");
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
private static List<MAPIFolder> GetFoldersToSearch(_NameSpace ns, string folder)
|
|
{
|
|
var folders = new List<MAPIFolder>();
|
|
var folderMap = new Dictionary<string, OlDefaultFolders>(StringComparer.OrdinalIgnoreCase)
|
|
{
|
|
["Inbox"] = OlDefaultFolders.olFolderInbox,
|
|
["SentMail"] = OlDefaultFolders.olFolderSentMail,
|
|
["Sent"] = OlDefaultFolders.olFolderSentMail,
|
|
["Drafts"] = OlDefaultFolders.olFolderDrafts,
|
|
["DeletedItems"] = OlDefaultFolders.olFolderDeletedItems,
|
|
["Deleted"] = OlDefaultFolders.olFolderDeletedItems,
|
|
["Trash"] = OlDefaultFolders.olFolderDeletedItems,
|
|
["Junk"] = OlDefaultFolders.olFolderJunk,
|
|
["Spam"] = OlDefaultFolders.olFolderJunk
|
|
};
|
|
|
|
if (folder.Equals("All", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
folders.Add(ns.GetDefaultFolder(OlDefaultFolders.olFolderInbox));
|
|
folders.Add(ns.GetDefaultFolder(OlDefaultFolders.olFolderSentMail));
|
|
// Also search subfolders of Inbox for "All"
|
|
var inbox = ns.GetDefaultFolder(OlDefaultFolders.olFolderInbox);
|
|
AddSubfolders(inbox, folders);
|
|
}
|
|
else if (folderMap.TryGetValue(folder, out var olFolder))
|
|
{
|
|
try
|
|
{
|
|
folders.Add(ns.GetDefaultFolder(olFolder));
|
|
}
|
|
catch
|
|
{
|
|
// Folder may not exist (e.g., Archive on some configurations)
|
|
}
|
|
}
|
|
else
|
|
{
|
|
// Search for custom folder by name
|
|
var customFolder = FindFolderByName(ns, folder);
|
|
if (customFolder != null)
|
|
{
|
|
folders.Add(customFolder);
|
|
}
|
|
}
|
|
|
|
return folders;
|
|
}
|
|
|
|
private static MAPIFolder? FindFolderByName(_NameSpace ns, string folderName)
|
|
{
|
|
// Search through all accounts/stores
|
|
foreach (var store in ns.Stores)
|
|
{
|
|
if (store is Store s)
|
|
{
|
|
try
|
|
{
|
|
var rootFolder = s.GetRootFolder() as MAPIFolder;
|
|
if (rootFolder != null)
|
|
{
|
|
var found = SearchFolderRecursive(rootFolder, folderName);
|
|
if (found != null)
|
|
return found;
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Skip stores that can't be accessed
|
|
}
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static MAPIFolder? SearchFolderRecursive(MAPIFolder parent, string folderName)
|
|
{
|
|
foreach (var subfolder in parent.Folders)
|
|
{
|
|
if (subfolder is MAPIFolder folder)
|
|
{
|
|
if (folder.Name.Equals(folderName, StringComparison.OrdinalIgnoreCase))
|
|
return folder;
|
|
|
|
var found = SearchFolderRecursive(folder, folderName);
|
|
if (found != null)
|
|
return found;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private static void AddSubfolders(MAPIFolder parent, List<MAPIFolder> folders)
|
|
{
|
|
foreach (var subfolder in parent.Folders)
|
|
{
|
|
if (subfolder is MAPIFolder folder)
|
|
{
|
|
folders.Add(folder);
|
|
AddSubfolders(folder, folders);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
items.Sort("[ReceivedTime]", true);
|
|
|
|
// Use date filter for performance
|
|
var filter = $"[ReceivedTime] >= '{targetDate:MM/dd/yyyy}' AND [ReceivedTime] < '{targetDate.AddDays(1):MM/dd/yyyy}'";
|
|
var filteredItems = items.Restrict(filter);
|
|
|
|
if (targetTime.HasValue)
|
|
{
|
|
// Find the email closest to the specified time
|
|
var targetDateTime = targetDate.Date + targetTime.Value;
|
|
MailItem? bestMatch = null;
|
|
var bestDiff = TimeSpan.MaxValue;
|
|
|
|
foreach (var item in filteredItems)
|
|
{
|
|
if (item is MailItem mail &&
|
|
mail.Subject != null &&
|
|
mail.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
var diff = (mail.ReceivedTime - targetDateTime).Duration();
|
|
if (diff < bestDiff)
|
|
{
|
|
bestDiff = diff;
|
|
bestMatch = mail;
|
|
}
|
|
}
|
|
}
|
|
|
|
return bestMatch;
|
|
}
|
|
|
|
foreach (var item in filteredItems)
|
|
{
|
|
if (item is MailItem mail &&
|
|
mail.Subject != null &&
|
|
mail.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
return mail;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private static void SearchFolder(
|
|
MAPIFolder folder,
|
|
SearchFilters filters,
|
|
DateTime cutoffDate,
|
|
int maxResults,
|
|
List<EmailResult> results)
|
|
{
|
|
try
|
|
{
|
|
var items = folder.Items;
|
|
items.Sort("[ReceivedTime]", true);
|
|
|
|
var filter = $"[ReceivedTime] >= '{cutoffDate:MM/dd/yyyy}'";
|
|
var filteredItems = items.Restrict(filter);
|
|
|
|
foreach (var item in filteredItems)
|
|
{
|
|
if (results.Count >= maxResults)
|
|
break;
|
|
|
|
if (item is MailItem mail && MatchesFilters(mail, filters))
|
|
{
|
|
results.Add(EmailResult.FromMailItem(mail, folder.Name));
|
|
}
|
|
}
|
|
}
|
|
catch
|
|
{
|
|
// Skip folders that can't be accessed
|
|
}
|
|
}
|
|
|
|
private static bool MatchesFilters(MailItem mail, SearchFilters filters)
|
|
{
|
|
// Sender filter
|
|
if (!string.IsNullOrEmpty(filters.Sender))
|
|
{
|
|
var senderMatch =
|
|
(mail.SenderName?.Contains(filters.Sender, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
|
(mail.SenderEmailAddress?.Contains(filters.Sender, StringComparison.OrdinalIgnoreCase) ?? false);
|
|
if (!senderMatch) return false;
|
|
}
|
|
|
|
// Subject filter
|
|
if (!string.IsNullOrEmpty(filters.Subject))
|
|
{
|
|
if (!(mail.Subject?.Contains(filters.Subject, StringComparison.OrdinalIgnoreCase) ?? false))
|
|
return false;
|
|
}
|
|
|
|
// Keywords filter
|
|
if (!string.IsNullOrEmpty(filters.Keywords))
|
|
{
|
|
var keywordMatch =
|
|
(mail.Subject?.Contains(filters.Keywords, StringComparison.OrdinalIgnoreCase) ?? false) ||
|
|
(mail.Body?.Contains(filters.Keywords, StringComparison.OrdinalIgnoreCase) ?? false);
|
|
if (!keywordMatch) return false;
|
|
}
|
|
|
|
// Attachment filter
|
|
if (!string.IsNullOrEmpty(filters.HasAttachment))
|
|
{
|
|
if (filters.HasAttachment.Equals("true", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (mail.Attachments.Count == 0) return false;
|
|
}
|
|
else if (filters.HasAttachment.Equals("false", StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
if (mail.Attachments.Count > 0) return false;
|
|
}
|
|
else
|
|
{
|
|
// Search for attachment by filename
|
|
var hasMatchingAttachment = false;
|
|
foreach (var attachment in mail.Attachments)
|
|
{
|
|
if (attachment is Attachment att &&
|
|
att.FileName.Contains(filters.HasAttachment, StringComparison.OrdinalIgnoreCase))
|
|
{
|
|
hasMatchingAttachment = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!hasMatchingAttachment) return false;
|
|
}
|
|
}
|
|
|
|
// Importance filter
|
|
if (filters.Importance.HasValue)
|
|
{
|
|
if (mail.Importance != filters.Importance.Value) return false;
|
|
}
|
|
|
|
// Category filter
|
|
if (!string.IsNullOrEmpty(filters.Category))
|
|
{
|
|
if (string.IsNullOrEmpty(mail.Categories)) return false;
|
|
if (!mail.Categories.Contains(filters.Category, StringComparison.OrdinalIgnoreCase)) return false;
|
|
}
|
|
|
|
// Flag status filter
|
|
if (filters.FlagStatus.HasValue)
|
|
{
|
|
if (mail.FlagStatus != filters.FlagStatus.Value) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
private static string FormatSearchResults(List<EmailResult> results, int offset, int totalCount)
|
|
{
|
|
var output = new StringBuilder();
|
|
|
|
if (offset > 0 || totalCount > results.Count)
|
|
output.AppendLine($"Showing {offset + 1}-{offset + results.Count} of {totalCount} email(s):");
|
|
else
|
|
output.AppendLine($"Found {results.Count} email(s):");
|
|
|
|
output.AppendLine();
|
|
|
|
for (int i = 0; i < results.Count; i++)
|
|
{
|
|
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}");
|
|
|
|
if (!string.IsNullOrEmpty(email.CC))
|
|
output.AppendLine($"CC: {email.CC}");
|
|
if (!string.IsNullOrEmpty(email.BCC))
|
|
output.AppendLine($"BCC: {email.BCC}");
|
|
|
|
output.AppendLine($"Date: {email.ReceivedDate:yyyy-MM-dd HH:mm}");
|
|
output.AppendLine($"Folder: {email.Folder}");
|
|
|
|
if (!string.IsNullOrEmpty(email.Importance) && email.Importance != "Normal")
|
|
output.AppendLine($"Importance: {email.Importance}");
|
|
|
|
if (!string.IsNullOrEmpty(email.Categories))
|
|
output.AppendLine($"Categories: {email.Categories}");
|
|
|
|
if (!string.IsNullOrEmpty(email.FlagStatus) && email.FlagStatus != "NotFlagged")
|
|
output.AppendLine($"Flag: {email.FlagStatus}");
|
|
|
|
if (email.AttachmentCount > 0)
|
|
output.AppendLine($"Attachments ({email.AttachmentCount}): {email.AttachmentNames}");
|
|
|
|
output.AppendLine($"Preview: {email.BodyPreview}");
|
|
output.AppendLine();
|
|
}
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
private static string FormatFullEmail(MailItem mail)
|
|
{
|
|
var output = new StringBuilder();
|
|
output.AppendLine($"Subject: {mail.Subject}");
|
|
output.AppendLine($"From: {mail.SenderName} <{mail.SenderEmailAddress}>");
|
|
output.AppendLine($"To: {mail.To}");
|
|
|
|
if (!string.IsNullOrEmpty(mail.CC))
|
|
output.AppendLine($"CC: {mail.CC}");
|
|
if (!string.IsNullOrEmpty(mail.BCC))
|
|
output.AppendLine($"BCC: {mail.BCC}");
|
|
|
|
output.AppendLine($"Date: {mail.ReceivedTime:yyyy-MM-dd HH:mm}");
|
|
|
|
if (mail.Importance != OlImportance.olImportanceNormal)
|
|
output.AppendLine($"Importance: {mail.Importance.ToString().Replace("olImportance", "")}");
|
|
|
|
if (!string.IsNullOrEmpty(mail.Categories))
|
|
output.AppendLine($"Categories: {mail.Categories}");
|
|
|
|
var attachmentNames = GetAttachmentNames(mail);
|
|
if (attachmentNames.Count > 0)
|
|
output.AppendLine($"Attachments ({attachmentNames.Count}): {string.Join(", ", attachmentNames)}");
|
|
|
|
output.AppendLine("---");
|
|
output.AppendLine();
|
|
output.Append(mail.Body);
|
|
|
|
return output.ToString();
|
|
}
|
|
|
|
private static List<string> GetAttachmentNames(MailItem mail)
|
|
{
|
|
var names = new List<string>();
|
|
foreach (var attachment in mail.Attachments)
|
|
{
|
|
if (attachment is Attachment att)
|
|
names.Add(att.FileName);
|
|
}
|
|
return names;
|
|
}
|
|
|
|
private static bool TryParseDate(string dateString, out DateTime result)
|
|
{
|
|
return DateTime.TryParseExact(
|
|
dateString,
|
|
SupportedDateFormats,
|
|
CultureInfo.InvariantCulture,
|
|
DateTimeStyles.None,
|
|
out result);
|
|
}
|
|
|
|
private static TimeSpan? TryParseTime(string? timeString)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(timeString))
|
|
return null;
|
|
|
|
if (DateTime.TryParse(timeString, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out var parsed))
|
|
return parsed.TimeOfDay;
|
|
|
|
return null;
|
|
}
|
|
|
|
private static OlImportance? ParseImportance(string? importance)
|
|
{
|
|
if (string.IsNullOrEmpty(importance)) return null;
|
|
|
|
return importance.ToLowerInvariant() switch
|
|
{
|
|
"high" => OlImportance.olImportanceHigh,
|
|
"low" => OlImportance.olImportanceLow,
|
|
"normal" => OlImportance.olImportanceNormal,
|
|
_ => null
|
|
};
|
|
}
|
|
|
|
private static OlFlagStatus? ParseFlagStatus(string? flagStatus)
|
|
{
|
|
if (string.IsNullOrEmpty(flagStatus)) return null;
|
|
|
|
return flagStatus.ToLowerInvariant() switch
|
|
{
|
|
"flagged" or "marked" => OlFlagStatus.olFlagMarked,
|
|
"completed" or "complete" => OlFlagStatus.olFlagComplete,
|
|
"notflagged" or "none" or "clear" => OlFlagStatus.olNoFlag,
|
|
_ => null
|
|
};
|
|
}
|
|
}
|