Compare commits

..

4 Commits

Author SHA1 Message Date
aj c72e81601c docs: document spam detection features and new MCP tools
Add spam detection architecture, detection patterns, attachment risk
scoring, and configuration details to CLAUDE.md.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:06:58 -05:00
aj f59b610d0b feat: add AI email agent and email watcher agent
EmailWatcherAgent monitors Outlook for new emails and sends Gotify
notifications based on configurable rules (importance, sender,
keywords, spam score). AIEmailAgent uses Claude API in an agentic
loop to evaluate emails and decide actions (notify, move, flag,
analyze spam). Program.cs updated to support --agent CLI mode.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:06:52 -05:00
aj 489cb8f657 feat: add MoveToJunk, AnalyzeSpam, and ScanForSpam MCP tools
MoveToJunk moves emails to Junk by subject/date. AnalyzeSpam returns
a detailed spam report with score, red flags, and sender/content
analysis. ScanForSpam batch-scans recent emails and ranks by spam
likelihood.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:06:43 -05:00
aj 51858cbd01 feat: add rule-based spam detection engine
Heuristic spam detector with 50+ patterns including SPF/DKIM/DMARC
auth checks, display name impersonation, URL analysis, attachment
risk scoring, and advanced phishing detection (fake quarantine
reports, voicemail scams, cold email solicitation). Configurable
via SpamDetectorConfig.json with customizable weights and blocklist.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-11 11:06:35 -05:00
17 changed files with 2676 additions and 7 deletions
+59
View File
@@ -25,12 +25,71 @@ This is an MCP (Model Context Protocol) server that provides Outlook email searc
- `EmailSearchTools.cs` - MCP tool implementations decorated with `[McpServerTool]`: - `EmailSearchTools.cs` - MCP tool implementations decorated with `[McpServerTool]`:
- `SearchEmails` - Search emails with filters (keywords, sender, subject, date range, folder, attachments, importance, category, flag status) - `SearchEmails` - Search emails with filters (keywords, sender, subject, date range, folder, attachments, importance, category, flag status)
- `ReadEmail` - Retrieve full email body by subject and date - `ReadEmail` - Retrieve full email body by subject and date
- `MoveToJunk` - Move an email to the Junk folder
- `AnalyzeSpam` - Analyze a specific email for spam indicators with detailed report
- `ScanForSpam` - Scan recent emails and return spam scores for potential spam
- `SearchFilters.cs` - Filter parameter container for email searches - `SearchFilters.cs` - Filter parameter container for email searches
- `EmailResult.cs` - DTO for search results with factory method `FromMailItem()` - `EmailResult.cs` - DTO for search results with factory method `FromMailItem()`
**Spam Detection (`SpamDetection/` folder):**
- `SpamDetector.cs` - Core rule-based spam detection engine with 50+ heuristic patterns
- `SpamFeatures.cs` - Feature extraction model for spam analysis
- `SpamAnalysisResult.cs` - Result container with score, likelihood, and red flags
- `SpamDetectorConfig.cs` - Configuration model with customizable weights and keyword lists
- `UrlAnalyzer.cs` - URL analysis (IP-based links, URL shorteners)
- `AttachmentAnalyzer.cs` - Attachment risk scoring by file type
- `FeatureExtractors.cs` - Helper methods for URL and header extraction
**Dependencies:** **Dependencies:**
- `ModelContextProtocol` - MCP SDK for .NET - `ModelContextProtocol` - MCP SDK for .NET
- `NetOfficeFw.Outlook` - COM interop wrapper for Outlook automation - `NetOfficeFw.Outlook` - COM interop wrapper for Outlook automation
**Platform:** Windows-only (.NET 9.0-windows) due to Outlook COM dependency **Platform:** Windows-only (.NET 9.0-windows) due to Outlook COM dependency
## Spam Detection Features
The spam detection system uses a weighted scoring approach (0.0-1.0) with the following detection patterns:
**Authentication Checks:**
- SPF, DKIM, DMARC authentication failures
- Reply-To domain mismatch
**Identity Spoofing:**
- Display name impersonation (vendor name + different domain)
- Subject domain impersonation
- Unicode/homoglyph attacks in domains
- Generic sender names (noreply, notification, etc.)
- Company subdomain spoofing (e.g., company.fakevoicemail.net)
**Link/URL Analysis:**
- IP address-based URLs
- URL shorteners (bit.ly, tinyurl, etc.)
- Suspicious TLDs (.xyz, .top, .click, etc.)
**Content Red Flags:**
- Keyword bait (invoice, urgent, verify, etc.)
- Placeholder text (failed mail merge)
- Single link with minimal text
- Tracking pixels (1x1 images)
- Zero-width Unicode characters (filter evasion)
- Random reference IDs in subject
- Timestamps in subject (automation indicator)
**Attachment Risk:**
- Weighted scoring by file type (0.0-1.0)
- Critical: .exe, .scr (1.0)
- High: .bat, .cmd, .vbs, .js (0.9-0.95)
- Medium: .docm, .xlsm, .html, .iso (0.6-0.8)
- Low: .zip, .rar (0.3-0.35)
**Advanced Phishing Patterns:**
- Fake quarantine/spam reports
- Fake voicemail notifications
- Fake system notifications (verify email, account suspended)
- Cold email solicitation (SEO, web design spam)
**Configuration:**
Optional `SpamDetectorConfig.json` and `BlockList.txt` files can be placed in the application directory to customize detection patterns, keywords, trusted domains, and score weights.
+17
View File
@@ -0,0 +1,17 @@
{
"anthropicApiKey": "",
"model": "claude-sonnet-4-20250514",
"watchFolder": "Inbox",
"gotifyUrl": "http://gotify.local:8080",
"gotifyToken": "YOUR_GOTIFY_APP_TOKEN",
"userPriorities": [
"Emails from my boss or direct team members",
"Urgent requests that need immediate attention",
"Customer inquiries and support tickets",
"Meeting changes and calendar updates",
"Security alerts from legitimate services (Microsoft, Google, etc.)",
"Invoices and payment confirmations from known vendors"
]
}
+100
View File
@@ -0,0 +1,100 @@
using System.Text.Json;
namespace EmailSearch.Agents;
/// <summary>
/// Entry point for running the AI Email Agent standalone.
/// </summary>
public static class AIAgentRunner
{
public static async Task RunAsync(CancellationToken cancellationToken = default)
{
Console.WriteLine("╔═══════════════════════════════════════════════════════════╗");
Console.WriteLine("║ AI EMAIL AGENT - Powered by Claude ║");
Console.WriteLine("╚═══════════════════════════════════════════════════════════╝");
Console.WriteLine();
var config = LoadConfig();
if (string.IsNullOrEmpty(config.AnthropicApiKey))
{
Console.WriteLine("ERROR: Anthropic API key not configured!");
Console.WriteLine();
Console.WriteLine("Set your API key via:");
Console.WriteLine(" 1. Environment variable: ANTHROPIC_API_KEY");
Console.WriteLine(" 2. Config file: AIAgentConfig.json");
Console.WriteLine();
return;
}
Console.WriteLine($"Model: {config.Model}");
Console.WriteLine($"Watching: {config.WatchFolder}");
Console.WriteLine($"Gotify: {(string.IsNullOrEmpty(config.GotifyUrl) ? "(not configured)" : config.GotifyUrl)}");
Console.WriteLine();
Console.WriteLine("User priorities:");
foreach (var p in config.UserPriorities)
Console.WriteLine($" • {p}");
Console.WriteLine();
Console.WriteLine("Press Ctrl+C to stop...");
Console.WriteLine();
using var agent = new AIEmailAgent(config);
await agent.StartAsync(cancellationToken);
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException) { }
await agent.StopAsync(CancellationToken.None);
}
private static AIAgentConfig LoadConfig()
{
var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIAgentConfig.json");
AIAgentConfig? config = null;
if (File.Exists(configPath))
{
try
{
var json = File.ReadAllText(configPath);
config = JsonSerializer.Deserialize<AIAgentConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to load config: {ex.Message}");
}
}
config ??= new AIAgentConfig();
// Override with environment variables if set
config.AnthropicApiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
?? config.AnthropicApiKey;
config.GotifyUrl = Environment.GetEnvironmentVariable("GOTIFY_URL")
?? config.GotifyUrl;
config.GotifyToken = Environment.GetEnvironmentVariable("GOTIFY_TOKEN")
?? config.GotifyToken;
// Default priorities if none configured
if (config.UserPriorities.Count == 0)
{
config.UserPriorities = new List<string>
{
"Emails from my boss or direct team members",
"Urgent or time-sensitive requests",
"Meeting changes and calendar updates",
"Security alerts from legitimate services",
"Customer inquiries that need quick response"
};
}
return config;
}
}
+685
View File
@@ -0,0 +1,685 @@
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.Hosting;
using NetOffice.OutlookApi;
using NetOffice.OutlookApi.Enums;
using EmailSearch.SpamDetection;
using OutlookApp = NetOffice.OutlookApi.Application;
using Exception = System.Exception;
namespace EmailSearch.Agents;
/// <summary>
/// A true AI Agent that uses Claude to reason about emails and decide what actions to take.
/// The LLM is in the decision loop - it evaluates each email and chooses actions dynamically.
/// </summary>
public class AIEmailAgent : BackgroundService
{
private readonly AIAgentConfig _config;
private readonly HttpClient _httpClient;
private readonly SpamDetector _spamDetector;
private OutlookApp? _outlookApp;
private _NameSpace? _namespace;
private MAPIFolder? _watchFolder;
private Items? _items;
// Tools the agent can use
private readonly List<AgentTool> _tools;
public AIEmailAgent(AIAgentConfig config)
{
_config = config;
_httpClient = new HttpClient();
_httpClient.DefaultRequestHeaders.Add("x-api-key", config.AnthropicApiKey);
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
_spamDetector = new SpamDetector();
// Define tools the agent can use
_tools = DefineTools();
}
private List<AgentTool> DefineTools()
{
return new List<AgentTool>
{
new AgentTool
{
Name = "send_notification",
Description = "Send a push notification to the user's phone via Gotify. Use for important emails that need immediate attention.",
InputSchema = new
{
type = "object",
properties = new
{
title = new { type = "string", description = "Short notification title" },
message = new { type = "string", description = "Notification body with key details" },
priority = new { type = "integer", description = "1-10, where 10 is most urgent. Use 8+ for truly urgent, 5-7 for important, 1-4 for informational" }
},
required = new[] { "title", "message", "priority" }
}
},
new AgentTool
{
Name = "move_to_folder",
Description = "Move the email to a specific Outlook folder. Use for organizing emails (e.g., move spam to Junk, receipts to Archive).",
InputSchema = new
{
type = "object",
properties = new
{
folder = new { type = "string", description = "Target folder: Junk, Archive, or any custom folder name" },
reason = new { type = "string", description = "Brief reason for moving" }
},
required = new[] { "folder", "reason" }
}
},
new AgentTool
{
Name = "flag_email",
Description = "Flag the email for follow-up with a category. Use when the email needs future attention but not immediate notification.",
InputSchema = new
{
type = "object",
properties = new
{
category = new { type = "string", description = "Category to apply: 'Follow Up', 'Review', 'Waiting', 'Important'" },
reason = new { type = "string", description = "Why this needs follow-up" }
},
required = new[] { "category" }
}
},
new AgentTool
{
Name = "log_observation",
Description = "Log an observation about the email without taking action. Use when the email is noted but requires no action.",
InputSchema = new
{
type = "object",
properties = new
{
observation = new { type = "string", description = "What you observed about this email" }
},
required = new[] { "observation" }
}
},
new AgentTool
{
Name = "analyze_spam",
Description = "Run detailed spam analysis on the email to get risk score and red flags. Use when you're unsure if an email is legitimate.",
InputSchema = new
{
type = "object",
properties = new
{
include_details = new { type = "boolean", description = "Include detailed feature breakdown" }
},
required = new string[] { }
}
}
};
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Log("AI Email Agent starting...");
Log($"Model: {_config.Model}");
Log($"Watching: {_config.WatchFolder}");
try
{
InitializeOutlook();
Log("Connected to Outlook. AI Agent ready and watching for emails...");
Log("");
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
catch (Exception ex)
{
Log($"Error: {ex.Message}");
}
}
private void InitializeOutlook()
{
_outlookApp = new OutlookApp();
_namespace = _outlookApp.GetNamespace("MAPI");
_watchFolder = _config.WatchFolder.Equals("Inbox", StringComparison.OrdinalIgnoreCase)
? _namespace.GetDefaultFolder(OlDefaultFolders.olFolderInbox)
: FindFolder(_namespace, _config.WatchFolder);
if (_watchFolder == null)
throw new Exception($"Folder '{_config.WatchFolder}' not found");
_items = (Items)_watchFolder.Items;
_items.ItemAddEvent += OnNewEmailReceived;
}
private void OnNewEmailReceived(object item)
{
if (item is not MailItem mail)
return;
// Run agent processing on a background thread
_ = Task.Run(async () =>
{
try
{
await ProcessEmailWithAgent(mail);
}
catch (Exception ex)
{
Log($"Agent error: {ex.Message}");
}
});
}
/// <summary>
/// The core agentic loop - sends email to Claude, executes tool calls, continues until done.
/// </summary>
private async Task ProcessEmailWithAgent(MailItem mail)
{
var subject = mail.Subject ?? "(No Subject)";
var sender = mail.SenderName ?? mail.SenderEmailAddress ?? "Unknown";
var senderEmail = mail.SenderEmailAddress ?? "";
var body = TruncateBody(mail.Body ?? "", 2000);
var receivedTime = mail.ReceivedTime;
Log($"");
Log($"═══════════════════════════════════════════════════════════");
Log($"NEW EMAIL: {subject}");
Log($"From: {sender} <{senderEmail}>");
Log($"Time: {receivedTime}");
Log($"═══════════════════════════════════════════════════════════");
// Build context for the agent
var systemPrompt = BuildSystemPrompt();
var userMessage = BuildEmailContext(mail, subject, sender, senderEmail, body);
var messages = new List<AgentMessage>
{
new AgentMessage { Role = "user", Content = userMessage }
};
// Agentic loop - keep going until Claude stops calling tools
var iteration = 0;
const int maxIterations = 5;
while (iteration < maxIterations)
{
iteration++;
Log($"");
Log($"[Agent thinking... iteration {iteration}]");
var response = await CallClaudeAsync(systemPrompt, messages);
if (response == null)
{
Log("[Agent] Failed to get response from Claude");
break;
}
// Process the response
var hasToolUse = false;
var toolResults = new List<ToolResultContent>();
foreach (var content in response.Content)
{
if (content.Type == "text" && !string.IsNullOrWhiteSpace(content.Text))
{
Log($"[Agent] {content.Text}");
}
else if (content.Type == "tool_use" && content.Input.HasValue)
{
hasToolUse = true;
Log($"[Agent calling tool: {content.Name}]");
// Execute the tool
var result = await ExecuteToolAsync(content.Name!, content.Input.Value, mail);
toolResults.Add(new ToolResultContent
{
Type = "tool_result",
ToolUseId = content.Id!,
Content = result
});
}
}
// If no tool calls, agent is done
if (!hasToolUse)
{
Log($"[Agent complete]");
break;
}
// Add assistant response and tool results to continue the conversation
messages.Add(new AgentMessage
{
Role = "assistant",
Content = response.Content
});
messages.Add(new AgentMessage
{
Role = "user",
Content = toolResults.Cast<object>().ToList()
});
}
Log($"───────────────────────────────────────────────────────────");
}
private string BuildSystemPrompt()
{
var priorities = _config.UserPriorities.Count > 0
? string.Join("\n", _config.UserPriorities.Select(p => $"- {p}"))
: "- No specific priorities configured";
return $"""
You are an AI email assistant that monitors incoming emails and takes appropriate actions.
## Your Role
You evaluate each incoming email and decide what action(s) to take. You have tools available
to send notifications, organize emails, flag for follow-up, or simply log observations.
## User's Priorities and Interests
The user has indicated these are important to them:
{priorities}
## Decision Guidelines
**Send notification (priority 8-10)** when:
- Email is from someone the user specifically cares about
- Contains urgent or time-sensitive information
- Requires immediate action or response
- Security alerts or account issues from legitimate sources
**Send notification (priority 5-7)** when:
- Important but not urgent information
- Updates the user would want to know about
- Meeting changes, calendar updates
**Flag for follow-up** when:
- Email needs a response but not immediately
- Contains tasks or action items
- Requires review when user has time
**Move to folder** when:
- Clear spam or phishing (move to Junk)
- Newsletters/marketing the user didn't ask for
- Receipts or automated confirmations (move to Archive)
**Log observation only** when:
- Routine email that needs no action
- Already handled by other actions
- Informational with no follow-up needed
## Important
- Be concise in your reasoning
- You can call multiple tools if appropriate
- If unsure about legitimacy, use analyze_spam first
- Don't over-notify - respect the user's attention
- Consider the sender, subject, and content together
Current time: {DateTime.Now:yyyy-MM-dd HH:mm}
""";
}
private string BuildEmailContext(MailItem mail, string subject, string sender, string senderEmail, string body)
{
var attachments = new List<string>();
if (mail.Attachments != null)
{
foreach (var att in mail.Attachments)
{
if (att is Attachment a)
attachments.Add(a.FileName);
}
}
var importance = mail.Importance switch
{
OlImportance.olImportanceHigh => "HIGH",
OlImportance.olImportanceLow => "Low",
_ => "Normal"
};
return $"""
A new email has arrived. Please evaluate it and decide what action(s) to take.
**From:** {sender} <{senderEmail}>
**Subject:** {subject}
**Importance:** {importance}
**Received:** {mail.ReceivedTime:yyyy-MM-dd HH:mm}
**Attachments:** {(attachments.Count > 0 ? string.Join(", ", attachments) : "None")}
**Body:**
{body}
What should I do with this email?
""";
}
private async Task<ClaudeResponse?> CallClaudeAsync(string systemPrompt, List<AgentMessage> messages)
{
var request = new
{
model = _config.Model,
max_tokens = 1024,
system = systemPrompt,
tools = _tools.Select(t => new
{
name = t.Name,
description = t.Description,
input_schema = t.InputSchema
}),
messages = messages.Select(m => new
{
role = m.Role,
content = m.Content
})
};
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
});
var content = new StringContent(json, Encoding.UTF8, "application/json");
try
{
var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content);
var responseJson = await response.Content.ReadAsStringAsync();
if (!response.IsSuccessStatusCode)
{
Log($"[API Error] {response.StatusCode}: {responseJson}");
return null;
}
return JsonSerializer.Deserialize<ClaudeResponse>(responseJson, new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
});
}
catch (Exception ex)
{
Log($"[API Exception] {ex.Message}");
return null;
}
}
private async Task<string> ExecuteToolAsync(string toolName, JsonElement input, MailItem mail)
{
try
{
switch (toolName)
{
case "send_notification":
return await ExecuteSendNotification(input);
case "move_to_folder":
return ExecuteMoveToFolder(input, mail);
case "flag_email":
return ExecuteFlagEmail(input, mail);
case "log_observation":
return ExecuteLogObservation(input);
case "analyze_spam":
return ExecuteAnalyzeSpam(input, mail);
default:
return $"Unknown tool: {toolName}";
}
}
catch (Exception ex)
{
return $"Tool error: {ex.Message}";
}
}
private async Task<string> ExecuteSendNotification(JsonElement input)
{
var title = input.GetProperty("title").GetString() ?? "Notification";
var message = input.GetProperty("message").GetString() ?? "";
var priority = input.TryGetProperty("priority", out var p) ? p.GetInt32() : 5;
Log($" → Sending notification: [{priority}] {title}");
if (!string.IsNullOrEmpty(_config.GotifyUrl) && !string.IsNullOrEmpty(_config.GotifyToken))
{
var url = $"{_config.GotifyUrl.TrimEnd('/')}/message?token={_config.GotifyToken}";
var payload = new { title, message, priority };
var response = await _httpClient.PostAsync(url,
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
if (response.IsSuccessStatusCode)
return $"Notification sent successfully (priority {priority})";
else
return $"Failed to send notification: {response.StatusCode}";
}
// Fallback: just log it
return $"Notification logged (Gotify not configured): [{priority}] {title} - {message}";
}
private string ExecuteMoveToFolder(JsonElement input, MailItem mail)
{
var folder = input.GetProperty("folder").GetString() ?? "Junk";
var reason = input.TryGetProperty("reason", out var r) ? r.GetString() : "No reason provided";
Log($" → Moving to {folder}: {reason}");
try
{
MAPIFolder? targetFolder = null;
if (folder.Equals("Junk", StringComparison.OrdinalIgnoreCase))
targetFolder = _namespace!.GetDefaultFolder(OlDefaultFolders.olFolderJunk);
else if (folder.Equals("Deleted", StringComparison.OrdinalIgnoreCase) ||
folder.Equals("Trash", StringComparison.OrdinalIgnoreCase))
targetFolder = _namespace!.GetDefaultFolder(OlDefaultFolders.olFolderDeletedItems);
else
targetFolder = FindFolder(_namespace!, folder);
if (targetFolder != null)
{
mail.Move(targetFolder);
return $"Email moved to {folder}";
}
else
{
return $"Folder '{folder}' not found";
}
}
catch (Exception ex)
{
return $"Failed to move email: {ex.Message}";
}
}
private string ExecuteFlagEmail(JsonElement input, MailItem mail)
{
var category = input.GetProperty("category").GetString() ?? "Follow Up";
var reason = input.TryGetProperty("reason", out var r) ? r.GetString() : "";
Log($" → Flagging as '{category}': {reason}");
try
{
// Add category
if (string.IsNullOrEmpty(mail.Categories))
mail.Categories = category;
else if (!mail.Categories.Contains(category))
mail.Categories += ", " + category;
// Set flag
mail.FlagStatus = OlFlagStatus.olFlagMarked;
mail.Save();
return $"Email flagged with category '{category}'";
}
catch (Exception ex)
{
return $"Failed to flag email: {ex.Message}";
}
}
private string ExecuteLogObservation(JsonElement input)
{
var observation = input.GetProperty("observation").GetString() ?? "";
Log($" → Observation: {observation}");
return "Observation logged";
}
private string ExecuteAnalyzeSpam(JsonElement input, MailItem mail)
{
var includeDetails = input.TryGetProperty("include_details", out var d) && d.GetBoolean();
var result = _spamDetector.Analyze(mail);
var response = new StringBuilder();
response.AppendLine($"Spam Score: {result.FinalScore:P0} ({result.SpamLikelihood})");
response.AppendLine($"Predicted Spam: {(result.PredictedSpam ? "YES" : "No")}");
if (result.RedFlags.Count > 0)
{
response.AppendLine($"Red Flags: {string.Join("; ", result.RedFlags)}");
}
if (includeDetails && result.Features != null)
{
response.AppendLine();
response.AppendLine("Details:");
response.AppendLine($" Domain: {result.Features.FromDomain}");
response.AppendLine($" Free email: {result.Features.FreeMailboxDomain}");
response.AppendLine($" Unknown domain: {result.Features.UnknownDomain}");
response.AppendLine($" Auth failures: SPF={result.Features.SpfFail}, DKIM={result.Features.DkimFail}");
response.AppendLine($" URLs: {result.Features.UrlCount}, Shortener: {result.Features.UsesShortener}");
response.AppendLine($" Attachment risk: {result.Features.AttachmentRiskScore:P0}");
}
Log($" → Spam analysis: {result.FinalScore:P0} - {result.SpamLikelihood}");
return response.ToString();
}
private static MAPIFolder? FindFolder(_NameSpace ns, string folderName)
{
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 { }
}
}
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 string TruncateBody(string body, int maxLength)
{
if (body.Length <= maxLength)
return body;
return body.Substring(0, maxLength) + "\n[... truncated ...]";
}
private void Log(string message)
{
Console.WriteLine($"{DateTime.Now:HH:mm:ss} {message}");
}
public override void Dispose()
{
_items?.Dispose();
_watchFolder?.Dispose();
_namespace?.Dispose();
_outlookApp?.Dispose();
_httpClient.Dispose();
base.Dispose();
}
}
// Configuration
public class AIAgentConfig
{
public string AnthropicApiKey { get; set; } = "";
public string Model { get; set; } = "claude-sonnet-4-20250514";
public string WatchFolder { get; set; } = "Inbox";
public string? GotifyUrl { get; set; }
public string? GotifyToken { get; set; }
public List<string> UserPriorities { get; set; } = new();
}
// Claude API types
public class AgentTool
{
public string Name { get; set; } = "";
public string Description { get; set; } = "";
public object InputSchema { get; set; } = new { };
}
public class AgentMessage
{
public string Role { get; set; } = "";
public object Content { get; set; } = "";
}
public class ToolResultContent
{
public string Type { get; set; } = "tool_result";
public string ToolUseId { get; set; } = "";
public string Content { get; set; } = "";
}
public class ClaudeResponse
{
public string Id { get; set; } = "";
public string Type { get; set; } = "";
public string Role { get; set; } = "";
public List<ContentBlock> Content { get; set; } = new();
public string StopReason { get; set; } = "";
}
public class ContentBlock
{
public string Type { get; set; } = "";
public string? Text { get; set; }
public string? Id { get; set; }
public string? Name { get; set; }
public JsonElement? Input { get; set; }
}
+101
View File
@@ -0,0 +1,101 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using System.Text.Json;
namespace EmailSearch.Agents;
/// <summary>
/// Standalone runner for the EmailWatcher agent.
/// Can be invoked from CLI or integrated into a host.
/// </summary>
public static class AgentRunner
{
/// <summary>
/// Run the email watcher agent as a standalone process.
/// </summary>
public static async Task RunEmailWatcherAsync(string? configPath = null, CancellationToken cancellationToken = default)
{
var config = LoadConfig(configPath);
Console.WriteLine("=== Email Watcher Agent ===");
Console.WriteLine($"Gotify URL: {config.GotifyUrl ?? "(not configured)"}");
Console.WriteLine($"Watching: {config.WatchFolder}");
Console.WriteLine($"Notify on high importance: {config.NotifyOnHighImportance}");
Console.WriteLine($"Notify on spam (>{config.SpamThreshold:P0}): {config.NotifyOnSpam}");
Console.WriteLine($"Watched senders: {string.Join(", ", config.WatchedSenders)}");
Console.WriteLine($"Watched keywords: {string.Join(", ", config.WatchedKeywords)}");
Console.WriteLine();
Console.WriteLine("Press Ctrl+C to stop...");
Console.WriteLine();
using var agent = new EmailWatcherAgent(config);
// Start the agent
await agent.StartAsync(cancellationToken);
// Wait until cancellation
try
{
await Task.Delay(Timeout.Infinite, cancellationToken);
}
catch (OperationCanceledException)
{
// Expected on Ctrl+C
}
await agent.StopAsync(CancellationToken.None);
}
/// <summary>
/// Build a host with the email watcher agent for integration with other services.
/// </summary>
public static IHostBuilder CreateHostBuilder(string? configPath = null)
{
var config = LoadConfig(configPath);
return Host.CreateDefaultBuilder()
.ConfigureServices(services =>
{
services.AddSingleton(config);
services.AddHostedService<EmailWatcherAgent>();
});
}
private static EmailWatcherConfig LoadConfig(string? configPath)
{
// Try loading from file
var path = configPath ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "EmailWatcherConfig.json");
if (File.Exists(path))
{
try
{
var json = File.ReadAllText(path);
var config = JsonSerializer.Deserialize<EmailWatcherConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
if (config != null)
return config;
}
catch (Exception ex)
{
Console.WriteLine($"Warning: Failed to load config from {path}: {ex.Message}");
}
}
// Fall back to environment variables
return new EmailWatcherConfig
{
WatchFolder = Environment.GetEnvironmentVariable("EMAIL_WATCH_FOLDER") ?? "Inbox",
GotifyUrl = Environment.GetEnvironmentVariable("GOTIFY_URL"),
GotifyToken = Environment.GetEnvironmentVariable("GOTIFY_TOKEN"),
WebhookUrl = Environment.GetEnvironmentVariable("WEBHOOK_URL"),
NotifyOnHighImportance = true,
NotifyOnSpam = true,
SpamThreshold = 0.7,
WatchedSenders = Environment.GetEnvironmentVariable("WATCH_SENDERS")?.Split(',').ToList() ?? new(),
WatchedKeywords = Environment.GetEnvironmentVariable("WATCH_KEYWORDS")?.Split(',').ToList() ?? new()
};
}
}
+304
View File
@@ -0,0 +1,304 @@
using Microsoft.Extensions.Hosting;
using System.Net.Http.Json;
using NetOffice.OutlookApi;
using NetOffice.OutlookApi.Enums;
using EmailSearch.SpamDetection;
using OutlookApp = NetOffice.OutlookApi.Application;
using Exception = System.Exception;
namespace EmailSearch.Agents;
/// <summary>
/// Background agent that watches for new emails and sends notifications
/// when interesting emails arrive (e.g., high-priority, from specific senders, or spam detected).
/// </summary>
public class EmailWatcherAgent : BackgroundService
{
private readonly EmailWatcherConfig _config;
private readonly HttpClient _httpClient;
private readonly SpamDetector _spamDetector;
private OutlookApp? _outlookApp;
private _NameSpace? _namespace;
private MAPIFolder? _watchFolder;
private Items? _items;
public EmailWatcherAgent(EmailWatcherConfig config)
{
_config = config;
_httpClient = new HttpClient();
_spamDetector = new SpamDetector();
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
Console.WriteLine($"[EmailWatcher] Starting... watching folder: {_config.WatchFolder}");
try
{
InitializeOutlook();
Console.WriteLine("[EmailWatcher] Connected to Outlook. Waiting for new emails...");
// Keep service alive
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
}
}
catch (Exception ex)
{
Console.WriteLine($"[EmailWatcher] Error: {ex.Message}");
}
}
private void InitializeOutlook()
{
_outlookApp = new OutlookApp();
_namespace = _outlookApp.GetNamespace("MAPI");
// Get the folder to watch
_watchFolder = _config.WatchFolder.Equals("Inbox", StringComparison.OrdinalIgnoreCase)
? _namespace.GetDefaultFolder(OlDefaultFolders.olFolderInbox)
: FindFolder(_namespace, _config.WatchFolder);
if (_watchFolder == null)
throw new Exception($"Folder '{_config.WatchFolder}' not found");
_items = (Items)_watchFolder.Items;
// Subscribe to new email events
_items.ItemAddEvent += OnNewEmailReceived;
Console.WriteLine($"[EmailWatcher] Watching folder: {_watchFolder.Name}");
}
private void OnNewEmailReceived(object item)
{
if (item is not MailItem mail)
return;
try
{
Console.WriteLine($"[EmailWatcher] New email: {mail.Subject}");
// Check if this email is "interesting" based on configured rules
var notification = EvaluateEmail(mail);
if (notification != null)
{
// Fire and forget the notification
_ = Task.Run(() => SendNotificationAsync(notification));
}
}
catch (Exception ex)
{
Console.WriteLine($"[EmailWatcher] Error processing email: {ex.Message}");
}
}
private NotificationPayload? EvaluateEmail(MailItem mail)
{
var subject = mail.Subject ?? "(No Subject)";
var sender = mail.SenderName ?? mail.SenderEmailAddress ?? "Unknown";
var senderEmail = mail.SenderEmailAddress ?? "";
// Rule 1: High importance emails
if (_config.NotifyOnHighImportance && mail.Importance == OlImportance.olImportanceHigh)
{
return new NotificationPayload
{
Title = "High Priority Email",
Message = $"From: {sender}\n{subject}",
Priority = 8
};
}
// Rule 2: Specific senders
if (_config.WatchedSenders.Any(s =>
senderEmail.Contains(s, StringComparison.OrdinalIgnoreCase) ||
sender.Contains(s, StringComparison.OrdinalIgnoreCase)))
{
return new NotificationPayload
{
Title = $"Email from {sender}",
Message = subject,
Priority = 7
};
}
// Rule 3: Keyword matches in subject
var matchedKeyword = _config.WatchedKeywords
.FirstOrDefault(k => subject.Contains(k, StringComparison.OrdinalIgnoreCase));
if (matchedKeyword != null)
{
return new NotificationPayload
{
Title = $"Email matching '{matchedKeyword}'",
Message = $"From: {sender}\n{subject}",
Priority = 6
};
}
// Rule 4: Spam detection alert
if (_config.NotifyOnSpam)
{
var spamResult = _spamDetector.Analyze(mail);
if (spamResult.FinalScore >= _config.SpamThreshold)
{
return new NotificationPayload
{
Title = $"Spam Detected ({spamResult.FinalScore:P0})",
Message = $"From: {sender}\n{subject}\nFlags: {string.Join(", ", spamResult.RedFlags.Take(3))}",
Priority = 5
};
}
}
// Rule 5: Notify on all emails (if enabled)
if (_config.NotifyOnAll)
{
return new NotificationPayload
{
Title = "New Email",
Message = $"From: {sender}\n{subject}",
Priority = 3
};
}
return null; // Not interesting
}
private async Task SendNotificationAsync(NotificationPayload notification)
{
try
{
if (!string.IsNullOrEmpty(_config.GotifyUrl) && !string.IsNullOrEmpty(_config.GotifyToken))
{
await SendGotifyAsync(notification);
}
if (!string.IsNullOrEmpty(_config.WebhookUrl))
{
await SendWebhookAsync(notification);
}
// Console output as fallback
Console.WriteLine($"[NOTIFY] {notification.Title}: {notification.Message}");
}
catch (Exception ex)
{
Console.WriteLine($"[EmailWatcher] Failed to send notification: {ex.Message}");
}
}
private async Task SendGotifyAsync(NotificationPayload notification)
{
var url = $"{_config.GotifyUrl.TrimEnd('/')}/message?token={_config.GotifyToken}";
var payload = new
{
title = notification.Title,
message = notification.Message,
priority = notification.Priority
};
var response = await _httpClient.PostAsJsonAsync(url, payload);
if (!response.IsSuccessStatusCode)
{
Console.WriteLine($"[Gotify] Failed: {response.StatusCode}");
}
}
private async Task SendWebhookAsync(NotificationPayload notification)
{
var payload = new
{
title = notification.Title,
message = notification.Message,
priority = notification.Priority,
timestamp = DateTime.UtcNow
};
await _httpClient.PostAsJsonAsync(_config.WebhookUrl!, payload);
}
private static MAPIFolder? FindFolder(_NameSpace ns, string folderName)
{
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 { }
}
}
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;
}
public override void Dispose()
{
Console.WriteLine("[EmailWatcher] Shutting down...");
_items?.Dispose();
_watchFolder?.Dispose();
_namespace?.Dispose();
_outlookApp?.Dispose();
_httpClient.Dispose();
base.Dispose();
}
}
public class NotificationPayload
{
public string Title { get; set; } = "";
public string Message { get; set; } = "";
public int Priority { get; set; } = 5; // Gotify: 1-10
}
public class EmailWatcherConfig
{
public string WatchFolder { get; set; } = "Inbox";
// Notification destinations
public string? GotifyUrl { get; set; } // e.g., "http://gotify.local:8080"
public string? GotifyToken { get; set; } // App token from Gotify
public string? WebhookUrl { get; set; } // Generic webhook endpoint
// What to notify on
public bool NotifyOnHighImportance { get; set; } = true;
public bool NotifyOnSpam { get; set; } = true;
public double SpamThreshold { get; set; } = 0.7;
public bool NotifyOnAll { get; set; } = false;
// Watch lists
public List<string> WatchedSenders { get; set; } = new(); // Email addresses or names
public List<string> WatchedKeywords { get; set; } = new(); // Subject keywords
}
+228
View File
@@ -5,6 +5,7 @@ using System.Text;
using OutlookApp = NetOffice.OutlookApi.Application; using OutlookApp = NetOffice.OutlookApi.Application;
using NetOffice.OutlookApi; using NetOffice.OutlookApi;
using NetOffice.OutlookApi.Enums; using NetOffice.OutlookApi.Enums;
using EmailSearch.SpamDetection;
[McpServerToolType] [McpServerToolType]
public class EmailSearchTools public class EmailSearchTools
@@ -75,6 +76,41 @@ 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.")]
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("Outlook folder to search for the email: Inbox, SentMail, Drafts, All, or any custom folder name (default Inbox)")] string folder = "Inbox")
{
try
{
if (!TryParseDate(date, out var targetDate))
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
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)
{
var mail = FindEmail(mailFolder, subject, targetDate);
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 subject and date.";
}
catch (System.Exception ex)
{
return $"Error moving email to junk: {ex.Message}";
}
}
[McpServerTool, Description("Read the full body of a specific email by subject and date. Use after SearchEmails to get complete email content.")] [McpServerTool, Description("Read the full body of a specific email by subject and date. Use after SearchEmails to get complete email content.")]
public static string ReadEmail( public static string ReadEmail(
[Description("Exact or partial subject line to match")] string subject, [Description("Exact or partial subject line to match")] string subject,
@@ -105,6 +141,198 @@ 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.")]
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("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All")
{
try
{
if (!TryParseDate(date, out var targetDate))
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
using var outlookApp = new OutlookApp();
var ns = outlookApp.GetNamespace("MAPI");
var foldersToSearch = GetFoldersToSearch(ns, folder);
foreach (var mailFolder in foldersToSearch)
{
var mail = FindEmail(mailFolder, subject, targetDate);
if (mail != null)
{
var detector = new SpamDetector();
var result = detector.Analyze(mail);
return FormatSpamAnalysis(mail, result);
}
}
return "Email not found with the specified subject and date.";
}
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($" 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) private static List<MAPIFolder> GetFoldersToSearch(_NameSpace ns, string folder)
{ {
var folders = new List<MAPIFolder>(); var folders = new List<MAPIFolder>();
+22
View File
@@ -0,0 +1,22 @@
{
"watchFolder": "Inbox",
"gotifyUrl": "http://gotify.local:8080",
"gotifyToken": "YOUR_GOTIFY_APP_TOKEN",
"webhookUrl": null,
"notifyOnHighImportance": true,
"notifyOnSpam": true,
"spamThreshold": 0.7,
"notifyOnAll": false,
"watchedSenders": [
"boss@company.com",
"important-client.com"
],
"watchedKeywords": [
"urgent",
"invoice",
"payment due"
]
}
+22 -4
View File
@@ -1,12 +1,30 @@
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server; using ModelContextProtocol.Server;
using EmailSearch.Agents;
var builder = Host.CreateApplicationBuilder(args); // Check for agent mode via command line args
builder.Services if (args.Length > 0 && args[0].Equals("--agent", StringComparison.OrdinalIgnoreCase))
{
// Run as AI Email Agent
var cts = new CancellationTokenSource();
Console.CancelKeyPress += (_, e) =>
{
e.Cancel = true;
cts.Cancel();
};
await AIAgentRunner.RunAsync(cts.Token);
}
else
{
// Run as MCP Server (default)
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddMcpServer() .AddMcpServer()
.WithStdioServerTransport() .WithStdioServerTransport()
.WithTools<EmailSearchTools>(); .WithTools<EmailSearchTools>();
var app = builder.Build(); var app = builder.Build();
await app.RunAsync(); await app.RunAsync();
}
@@ -0,0 +1,72 @@
using NetOffice.OutlookApi;
namespace EmailSearch.SpamDetection;
internal static class AttachmentAnalyzer
{
// Risk scores by extension type (0.0 = safe, 1.0 = very dangerous)
private static readonly Dictionary<string, double> AttachmentRiskScores = new(StringComparer.OrdinalIgnoreCase)
{
// Critical risk - direct executables
{ ".exe", 1.0 },
{ ".scr", 1.0 },
{ ".bat", 0.95 },
{ ".cmd", 0.95 },
{ ".com", 0.95 },
{ ".pif", 0.95 },
{ ".msi", 0.9 },
{ ".vbs", 0.9 },
{ ".js", 0.9 },
{ ".ps1", 0.9 },
{ ".wsf", 0.9 },
// High risk - macro-enabled documents
{ ".docm", 0.8 },
{ ".xlsm", 0.8 },
{ ".pptm", 0.8 },
{ ".xlam", 0.8 },
// Medium-high risk - can contain executables
{ ".iso", 0.7 },
{ ".img", 0.7 },
{ ".lnk", 0.75 },
{ ".hta", 0.7 },
// Medium risk - HTML can be phishing
{ ".html", 0.6 },
{ ".htm", 0.6 },
{ ".svg", 0.5 },
// Low-medium risk - archives
{ ".zip", 0.3 },
{ ".rar", 0.35 },
{ ".7z", 0.35 },
{ ".tar", 0.3 },
{ ".gz", 0.3 }
};
public static double GetAttachmentRiskScore(MailItem mail)
{
if (mail.Attachments == null || mail.Attachments.Count == 0)
return 0.0;
double maxRisk = 0.0;
foreach (var attachment in mail.Attachments)
{
if (attachment is Attachment att)
{
var name = att.FileName?.ToLowerInvariant() ?? "";
foreach (var kvp in AttachmentRiskScores)
{
if (name.EndsWith(kvp.Key))
{
maxRisk = Math.Max(maxRisk, kvp.Value);
}
}
}
}
return maxRisk;
}
}
@@ -0,0 +1,30 @@
using System.Text.RegularExpressions;
namespace EmailSearch.SpamDetection;
internal static class FeatureExtractors
{
public static List<string> ExtractUrls(string text) =>
Regex.Matches(text ?? "", @"https?://[^\s'""<>()]+", RegexOptions.IgnoreCase)
.Select(m => m.Value)
.ToList();
public static string ExtractFirstEmail(string headerLine)
{
var m = Regex.Match(
headerLine ?? "",
@"[A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,}",
RegexOptions.IgnoreCase);
return m.Success ? m.Value : "";
}
public static string MatchHeader(string headers, string pattern)
{
if (string.IsNullOrEmpty(headers)) return string.Empty;
var match = Regex.Match(
headers,
pattern,
RegexOptions.IgnoreCase | RegexOptions.Multiline);
return match.Success ? match.Groups["val"].Value : string.Empty;
}
}
@@ -0,0 +1,25 @@
namespace EmailSearch.SpamDetection;
/// <summary>
/// Result of spam analysis containing scores and detected features.
/// </summary>
public sealed class SpamAnalysisResult
{
public double RuleBasedScore { get; set; }
public double FinalScore { get; set; }
public bool PredictedSpam { get; set; }
public SpamFeatures? Features { get; set; }
public List<string> RedFlags { get; set; } = new();
/// <summary>
/// Gets a human-readable spam likelihood category.
/// </summary>
public string SpamLikelihood => FinalScore switch
{
>= 0.9 => "Very High",
>= 0.7 => "High",
>= 0.5 => "Medium",
>= 0.3 => "Low",
_ => "Very Low"
};
}
+686
View File
@@ -0,0 +1,686 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using NetOffice.OutlookApi;
using NetOffice.OutlookApi.Enums;
namespace EmailSearch.SpamDetection;
public sealed class SpamDetector
{
private readonly SpamDetectorConfig _config;
private readonly HashSet<string> _blocklistEmails;
private readonly HashSet<string> _blocklistDomains;
public SpamDetector() : this(null) { }
public SpamDetector(SpamDetectorConfig? config)
{
_config = config ?? LoadConfiguration() ?? SpamDetectorConfig.GetDefault();
(_blocklistEmails, _blocklistDomains) = LoadBlocklist();
}
private static SpamDetectorConfig? LoadConfiguration()
{
try
{
var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "SpamDetectorConfig.json");
if (File.Exists(configPath))
{
var json = File.ReadAllText(configPath);
return JsonSerializer.Deserialize<SpamDetectorConfig>(json, new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
}
}
catch { }
return null;
}
private static (HashSet<string> emails, HashSet<string> domains) LoadBlocklist()
{
var emails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var domains = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
try
{
var blocklistPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "BlockList.txt");
if (!File.Exists(blocklistPath))
return (emails, domains);
var lines = File.ReadAllLines(blocklistPath, System.Text.Encoding.Unicode);
foreach (var line in lines)
{
var entry = line.Trim();
if (string.IsNullOrWhiteSpace(entry))
continue;
if (entry.StartsWith("@"))
domains.Add(entry.Substring(1).ToLowerInvariant());
else if (entry.Contains("@"))
emails.Add(entry.ToLowerInvariant());
}
}
catch { }
return (emails, domains);
}
public SpamAnalysisResult Analyze(MailItem mailItem)
{
var features = BuildFeatures(mailItem);
var score = CalculateScore(features);
var redFlags = GetRedFlags(features);
return new SpamAnalysisResult
{
RuleBasedScore = score,
FinalScore = score,
PredictedSpam = score >= 0.7,
Features = features,
RedFlags = redFlags
};
}
private SpamFeatures BuildFeatures(MailItem m)
{
var f = new SpamFeatures();
// Sender/display
f.DisplayName = m.SenderName ?? "";
f.FromAddress = GetSmtpAddress(m);
f.FromDomain = DomainOf(f.FromAddress);
// Body/headers
var headers = GetInternetHeaders(m);
var bodyPreview = (m.Body ?? "").Trim();
var html = m.HTMLBody ?? "";
// Auth results
f.SpfFail = Contains(headers, "spf=fail");
f.DkimFail = Contains(headers, "dkim=fail");
f.DmarcFail = Contains(headers, "dmarc=fail");
// Reply-To mismatch
var replyTo = FeatureExtractors.MatchHeader(headers, @"(?im)^\s*Reply-To:\s*(?<val>.+)$");
var replyToAddr = FeatureExtractors.ExtractFirstEmail(replyTo);
f.ReplyToDomainMismatch = !string.IsNullOrEmpty(replyToAddr) &&
!string.Equals(DomainOf(replyToAddr), f.FromDomain, StringComparison.OrdinalIgnoreCase);
// Display name impersonation
f.DisplayImpersonation = LooksLikeVendorName(f.DisplayName) && !IsKnownVendorDomain(f.FromDomain);
// Subject domain impersonation
f.SubjectDomainImpersonation = CheckSubjectDomainImpersonation(m.Subject ?? "", f.FromDomain);
// URLs
var urls = FeatureExtractors.ExtractUrls(html.Length > 0 ? html : bodyPreview);
f.UrlCount = urls.Count;
f.HasUrl = f.UrlCount > 0;
f.HasIpLink = urls.Any(u => UrlAnalyzer.IsIpUrl(u));
f.UsesShortener = urls.Any(u => UrlAnalyzer.IsShortener(u));
// Suspicious TLDs
f.SuspiciousTld = _config.BadTlds.Contains(TldOf(f.FromDomain), StringComparer.OrdinalIgnoreCase) ||
urls.Any(url => HasSuspiciousTld(url));
// Free-mail domains
f.FreeMailboxDomain = _config.FreeDomains.Contains(f.FromDomain, StringComparer.OrdinalIgnoreCase);
// Unknown domain
f.UnknownDomain = !string.IsNullOrEmpty(f.FromDomain) &&
!_config.Domains.Vendors.ContainsKey(f.FromDomain) &&
!_config.Domains.Trusted.Any(d => f.FromDomain.Equals(d, StringComparison.OrdinalIgnoreCase));
// Blocklist
f.IsBlocklisted = IsBlocklisted(f.FromAddress, f.FromDomain);
// Tracking pixel
f.HasTrackingPixel = Regex.IsMatch(html,
@"<img[^>]+(width=['""]?1['""]?[^>]*height=['""]?1['""]?|height=['""]?1['""]?[^>]*width=['""]?1['""]?)",
RegexOptions.IgnoreCase);
// Attachments
f.HasAttachment = m.Attachments?.Count > 0;
f.AttachmentRiskScore = AttachmentAnalyzer.GetAttachmentRiskScore(m);
f.HasRiskyAttachment = f.AttachmentRiskScore >= 0.5;
// Keyword bait
var lower = (m.Subject + " " + bodyPreview).ToLowerInvariant();
f.KeywordBait = _config.BaitKeywords.Any(k => lower.Contains(k, StringComparison.OrdinalIgnoreCase));
// Placeholder text
f.HasPlaceholderText = HasPlaceholderText(m.Subject + " " + bodyPreview);
// Generic sender
f.GenericSenderName = IsGenericSender(f.DisplayName, f.FromAddress);
// Single link with minimal text
var isMinimal = IsMinimalContent(bodyPreview, html);
f.SingleLinkOnly = f.UrlCount == 1 && bodyPreview.Length < 2000 && isMinimal;
// Unicode lookalikes
f.UnicodeLookalike = HasHomoglyphs(f.FromDomain);
// Reputation
f.SenderReputation = _config.Domains.Vendors.TryGetValue(f.FromDomain, out var vendorInfo)
? vendorInfo.Reputation
: 0;
// List-Unsubscribe header
f.HasListUnsub = Contains(headers, "List-Unsubscribe:");
// Advanced patterns
f.CompanySubdomainSpoof = CheckCompanySubdomainSpoof(f.FromDomain);
f.FakeQuarantineReport = CheckFakeQuarantineReport(m.Subject ?? "", bodyPreview, f.FromDomain);
f.HasZeroWidthChars = HasZeroWidthCharacters(m.Subject + " " + f.DisplayName + " " + bodyPreview);
f.HasRandomRefId = HasRandomReferenceId(m.Subject ?? "");
f.HasTimestampInSubject = HasTimestampInSubject(m.Subject ?? "");
f.ColdEmailSolicitation = CheckColdEmailSolicitation(m.Subject ?? "", bodyPreview);
f.FakeVoicemailNotification = CheckFakeVoicemailNotification(m.Subject ?? "", bodyPreview, f.FromDomain);
f.FakeSystemNotification = CheckFakeSystemNotification(m.Subject ?? "", bodyPreview, f.FromDomain);
return f;
}
private double CalculateScore(SpamFeatures f)
{
var w = _config.SpamScoreWeights;
double s = 0;
// Auth & identity
if (f.SpfFail) s += w.SpfFail;
if (f.DkimFail) s += w.DkimFail;
if (f.DmarcFail) s += w.DmarcFail;
if (f.ReplyToDomainMismatch) s += w.ReplyToDomainMismatch;
if (f.DisplayImpersonation) s += w.DisplayImpersonation;
if (f.UnicodeLookalike) s += w.UnicodeLookalike;
if (f.GenericSenderName) s += 0.18;
if (f.SubjectDomainImpersonation) s += 0.35;
if (f.IsBlocklisted) s += 0.95;
// Content/links
if (f.HasUrl) s += w.HasUrl + Math.Min(0.10, f.UrlCount * w.UrlCountMultiplier);
if (f.HasIpLink) s += w.HasIpLink;
if (f.UsesShortener) s += w.UsesShortener;
if (f.SuspiciousTld) s += w.SuspiciousTld;
if (f.HasTrackingPixel) s += w.HasTrackingPixel;
// Attachments & bait
if (f.HasAttachment) s += w.HasAttachment;
s += f.AttachmentRiskScore * w.HasRiskyAttachment;
if (f.KeywordBait) s += w.KeywordBait;
if (f.HasPlaceholderText) s += 0.30;
if (f.SingleLinkOnly) s += 0.25;
// Unknown domain
if (f.UnknownDomain)
{
s += w.UnknownDomain;
if (f.KeywordBait || f.UsesShortener)
s += 0.15;
}
// Freemail
if (f.FreeMailboxDomain && f.HasUrl) s += w.FreeMailboxWithUrl;
else if (f.FreeMailboxDomain) s += w.FreeMailboxOnly;
// Reputation
s += Math.Clamp(-w.ReputationMultiplier * f.SenderReputation, -0.25, 0.25);
// Legitimacy signals
if (f.HasListUnsub) s += w.HasListUnsubscribe;
// Advanced patterns
if (f.CompanySubdomainSpoof) s += w.CompanySubdomainSpoof;
if (f.FakeQuarantineReport) s += w.FakeQuarantineReport;
if (f.HasZeroWidthChars) s += w.HasZeroWidthChars;
if (f.HasRandomRefId) s += w.HasRandomRefId;
if (f.HasTimestampInSubject) s += w.HasTimestampInSubject;
if (f.ColdEmailSolicitation) s += w.ColdEmailSolicitation;
if (f.FakeVoicemailNotification) s += w.FakeVoicemailNotification;
if (f.FakeSystemNotification) s += w.FakeSystemNotification;
return Math.Max(0, Math.Min(1, s));
}
private List<string> GetRedFlags(SpamFeatures f)
{
var flags = new List<string>();
if (f.IsBlocklisted) flags.Add("Sender is blocklisted");
if (f.SpfFail) flags.Add("SPF authentication failed");
if (f.DkimFail) flags.Add("DKIM authentication failed");
if (f.DmarcFail) flags.Add("DMARC authentication failed");
if (f.ReplyToDomainMismatch) flags.Add("Reply-To domain doesn't match sender");
if (f.DisplayImpersonation) flags.Add("Display name may impersonate known vendor");
if (f.SubjectDomainImpersonation) flags.Add("Subject mentions known domain but sender differs");
if (f.UnicodeLookalike) flags.Add("Domain contains suspicious Unicode characters");
if (f.GenericSenderName) flags.Add("Generic/automated sender name");
if (f.HasIpLink) flags.Add("Contains IP address-based URL");
if (f.UsesShortener) flags.Add("Uses URL shortener service");
if (f.SuspiciousTld) flags.Add("Suspicious top-level domain");
if (f.HasRiskyAttachment) flags.Add($"Risky attachment type (risk: {f.AttachmentRiskScore:P0})");
if (f.KeywordBait) flags.Add("Contains spam/phishing keywords");
if (f.HasPlaceholderText) flags.Add("Contains placeholder/merge field text");
if (f.SingleLinkOnly) flags.Add("Minimal content with single link");
if (f.CompanySubdomainSpoof) flags.Add("Subdomain spoofing detected");
if (f.FakeQuarantineReport) flags.Add("Fake quarantine/spam report");
if (f.HasZeroWidthChars) flags.Add("Contains zero-width characters (filter evasion)");
if (f.HasRandomRefId) flags.Add("Random reference ID in subject");
if (f.HasTimestampInSubject) flags.Add("Automated timestamp in subject");
if (f.ColdEmailSolicitation) flags.Add("Cold email solicitation");
if (f.FakeVoicemailNotification) flags.Add("Fake voicemail notification");
if (f.FakeSystemNotification) flags.Add("Fake system notification");
if (f.FreeMailboxDomain && f.HasUrl) flags.Add("Free email with links (potential phishing)");
return flags;
}
// ---- Helper Methods ----
private static string GetSmtpAddress(MailItem m)
{
try
{
if (m.Sender != null)
{
var addressEntry = m.Sender;
if (addressEntry.AddressEntryUserType == OlAddressEntryUserType.olSmtpAddressEntry)
{
return Safe(addressEntry.Address);
}
if (addressEntry.AddressEntryUserType == OlAddressEntryUserType.olExchangeUserAddressEntry ||
addressEntry.AddressEntryUserType == OlAddressEntryUserType.olExchangeRemoteUserAddressEntry)
{
try
{
var pa = addressEntry.PropertyAccessor;
var smtpAddress = pa.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x39FE001E");
if (smtpAddress is string s && !string.IsNullOrEmpty(s))
{
return s.Trim();
}
}
catch { }
try
{
var exchangeUser = addressEntry.GetExchangeUser();
if (exchangeUser != null && !string.IsNullOrEmpty(exchangeUser.PrimarySmtpAddress))
{
return exchangeUser.PrimarySmtpAddress.Trim();
}
}
catch { }
}
}
var senderEmail = m.SenderEmailAddress ?? "";
if (senderEmail.StartsWith("/O=", StringComparison.OrdinalIgnoreCase))
{
var headers = GetInternetHeaders(m);
var fromHeader = FeatureExtractors.MatchHeader(headers, @"(?im)^\s*From:\s*(?<val>.+)$");
var extractedEmail = FeatureExtractors.ExtractFirstEmail(fromHeader);
if (!string.IsNullOrEmpty(extractedEmail))
{
return extractedEmail;
}
}
return Safe(senderEmail);
}
catch
{
return Safe(m.SenderEmailAddress);
}
}
private static string GetInternetHeaders(MailItem m)
{
try
{
var pa = m.PropertyAccessor;
var raw = pa.GetProperty("http://schemas.microsoft.com/mapi/proptag/0x007D001E");
return raw is string s ? s : "";
}
catch { return ""; }
}
private static string Safe(string? s) => s?.Trim() ?? "";
private static bool Contains(string hay, string needle) =>
hay?.IndexOf(needle, StringComparison.OrdinalIgnoreCase) >= 0;
private static string DomainOf(string email)
{
var at = email.IndexOf('@');
if (at < 0) return "";
return email[(at + 1)..].Trim().ToLowerInvariant();
}
private static string TldOf(string domain)
{
var dot = domain.LastIndexOf('.');
return dot >= 0 ? domain[(dot + 1)..] : domain;
}
private bool LooksLikeVendorName(string name)
{
if (string.IsNullOrEmpty(name))
return false;
var patterns = _config.Domains.Vendors.Values
.SelectMany(v => v.DisplayNamePatterns)
.Where(p => !string.IsNullOrEmpty(p))
.ToList();
if (patterns.Count == 0)
return false;
var pattern = "(" + string.Join("|", patterns) + ")";
return Regex.IsMatch(name, pattern, RegexOptions.IgnoreCase);
}
private bool IsKnownVendorDomain(string domain)
{
if (string.IsNullOrEmpty(domain))
return false;
return _config.Domains.Vendors.ContainsKey(domain);
}
private static bool HasHomoglyphs(string domain) =>
domain.Any(ch => ch > 127);
private static bool IsMinimalContent(string bodyText, string html)
{
try
{
if (string.IsNullOrWhiteSpace(html))
return bodyText.Length < 200;
// Strip HTML tags for word count
var plainText = Regex.Replace(html, "<[^>]+>", " ");
plainText = System.Net.WebUtility.HtmlDecode(plainText);
plainText = Regex.Replace(plainText, @"\s+", " ").Trim();
var words = plainText.Split(new[] { ' ', '\t', '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries)
.Where(w => w.Length > 1)
.ToArray();
return words.Length < 50;
}
catch
{
return bodyText.Length < 200;
}
}
private static bool IsGenericSender(string displayName, string fromAddress)
{
var genericPatterns = new[]
{
"noreply", "no-reply", "no.reply", "donotreply", "do-not-reply",
"notification", "notify", "alert", "system", "admin", "administrator",
"support", "helpdesk", "help.desk", "technical support", "tech support",
"voice message", "voicemail", "fax", "scanner", "document center",
"storage center", "help center", "incident", "ticket"
};
var lowerDisplay = displayName.ToLowerInvariant();
var lowerAddress = fromAddress.ToLowerInvariant();
return genericPatterns.Any(p =>
lowerDisplay.Contains(p, StringComparison.OrdinalIgnoreCase) ||
lowerAddress.Contains(p, StringComparison.OrdinalIgnoreCase));
}
private bool HasSuspiciousTld(string url)
{
try
{
var host = new Uri(url).Host.ToLowerInvariant();
var tld = TldOf(host);
return _config.BadTlds.Contains(tld, StringComparer.OrdinalIgnoreCase);
}
catch { return false; }
}
private static bool HasPlaceholderText(string text)
{
if (string.IsNullOrWhiteSpace(text))
return false;
var placeholderKeywords = new[]
{
"email", "name", "user", "recipient", "customer", "client",
"address", "company", "account", "localpart", "domain"
};
var keywordPattern = string.Join("|", placeholderKeywords);
var bracketPatterns = new[]
{
$@"\[.*?(?:{keywordPattern}).*?\]",
$@"\{{.*?(?:{keywordPattern}).*?\}}",
$@"\{{\{{.*?(?:{keywordPattern}).*?\}}\}}",
$@"<.*?(?:{keywordPattern}).*?>",
$@"\$\{{.*?(?:{keywordPattern}).*?\}}",
$@"%.*?(?:{keywordPattern}).*?%"
};
return bracketPatterns.Any(p => Regex.IsMatch(text, p, RegexOptions.IgnoreCase));
}
private bool IsBlocklisted(string fromAddress, string fromDomain)
{
if (_blocklistEmails.Contains(fromAddress.ToLowerInvariant()))
return true;
if (_blocklistDomains.Contains(fromDomain.ToLowerInvariant()))
return true;
return false;
}
private bool CheckSubjectDomainImpersonation(string subject, string fromDomain)
{
if (string.IsNullOrWhiteSpace(subject))
return false;
var subjectLower = subject.ToLowerInvariant();
foreach (var vendorDomain in _config.Domains.Vendors.Keys)
{
if (fromDomain.Equals(vendorDomain, StringComparison.OrdinalIgnoreCase))
continue;
var pattern = $@"\b{Regex.Escape(vendorDomain)}\b";
if (Regex.IsMatch(subjectLower, pattern, RegexOptions.IgnoreCase))
return true;
}
foreach (var trustedDomain in _config.Domains.Trusted)
{
if (fromDomain.Equals(trustedDomain, StringComparison.OrdinalIgnoreCase))
continue;
var pattern = $@"\b{Regex.Escape(trustedDomain)}\b";
if (Regex.IsMatch(subjectLower, pattern, RegexOptions.IgnoreCase))
return true;
}
return false;
}
private bool CheckCompanySubdomainSpoof(string fromDomain)
{
if (string.IsNullOrEmpty(fromDomain))
return false;
var parts = fromDomain.Split('.');
if (parts.Length < 3)
return false;
var subdomain = parts[0].ToLowerInvariant();
foreach (var vendor in _config.Domains.Vendors)
{
var vendorDomainParts = vendor.Key.Split('.');
var vendorName = vendorDomainParts[0].ToLowerInvariant();
if (subdomain.Contains(vendorName, StringComparison.OrdinalIgnoreCase))
{
if (!fromDomain.Equals(vendor.Key, StringComparison.OrdinalIgnoreCase))
return true;
}
}
var fakeServiceDomains = _config.FakeServiceDomains.Count > 0
? _config.FakeServiceDomains
: GetDefaultFakeServiceDomains();
var baseDomain = string.Join(".", parts.Skip(1));
return fakeServiceDomains.Any(fsd => baseDomain.EndsWith(fsd, StringComparison.OrdinalIgnoreCase));
}
private static List<string> GetDefaultFakeServiceDomains() => new()
{
"voiceservicing.net", "audios.net", "voicemail.net", "audioservices.net",
"mailservicing.net", "emailservicing.net", "securemail.net", "mailprotect.net"
};
private bool CheckFakeQuarantineReport(string subject, string body, string fromDomain)
{
var text = (subject + " " + body).ToLowerInvariant();
var quarantineKeywords = _config.QuarantineKeywords.Count > 0
? _config.QuarantineKeywords
: new List<string> { "quarantine summary", "spam report", "quarantine folder", "email quarantine" };
var hasQuarantineKeyword = quarantineKeywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
if (!hasQuarantineKeyword)
return false;
var legitimateQuarantineDomains = new[]
{
"microsoft.com", "office365.com", "mimecast.com", "proofpoint.com",
"barracuda.com", "sophos.com", "fortinet.com", "cisco.com"
};
return !legitimateQuarantineDomains.Any(d => fromDomain.EndsWith(d, StringComparison.OrdinalIgnoreCase)) &&
!_config.Domains.Vendors.Keys.Any(v => fromDomain.Equals(v, StringComparison.OrdinalIgnoreCase));
}
private static bool HasZeroWidthCharacters(string text)
{
if (string.IsNullOrEmpty(text))
return false;
var zeroWidthChars = new[]
{
'\u200B', '\u200C', '\u200D', '\u200E', '\u200F',
'\u2060', '\uFEFF', '\u00AD', '\u034F', '\u061C',
'\u115F', '\u1160', '\u17B4', '\u17B5', '\u180E'
};
return text.Any(c => zeroWidthChars.Contains(c));
}
private static bool HasRandomReferenceId(string subject)
{
if (string.IsNullOrEmpty(subject))
return false;
var patterns = new[]
{
@"Ref[:#]?\s*[A-Za-z0-9]{20,}",
@"#\d{8}[-_]?[A-Za-z0-9]{8,}",
@"ID[:#]?\s*[A-Za-z0-9]{15,}",
@"[A-Za-z0-9]{25,}",
@"_[A-Za-z0-9]{20,}"
};
return patterns.Any(p => Regex.IsMatch(subject, p, RegexOptions.IgnoreCase));
}
private static bool HasTimestampInSubject(string subject)
{
if (string.IsNullOrEmpty(subject))
return false;
var patterns = new[]
{
@"<\d{2}:\d{2}:\d{2}\.\d{3}\s+\d{2}/\d{2}/\d{4}>",
@"\[\d{2}:\d{2}:\d{2}\]",
@"\(\d{2}:\d{2}:\d{2}\)",
@"\d{2}:\d{2}:\d{2}\.\d{3}"
};
return patterns.Any(p => Regex.IsMatch(subject, p));
}
private bool CheckColdEmailSolicitation(string subject, string body)
{
var text = (subject + " " + body).ToLowerInvariant();
var coldEmailKeywords = _config.ColdEmailKeywords.Count > 0
? _config.ColdEmailKeywords
: new List<string> { "seo services", "website design", "reaching out", "hope this finds you" };
var matchCount = coldEmailKeywords.Count(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
return matchCount >= 2;
}
private bool CheckFakeVoicemailNotification(string subject, string body, string fromDomain)
{
var text = (subject + " " + body).ToLowerInvariant();
var voicemailKeywords = _config.VoicemailKeywords.Count > 0
? _config.VoicemailKeywords
: new List<string> { "voicemail", "voice message", "missed call" };
var hasVoicemailKeyword = voicemailKeywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
if (!hasVoicemailKeyword)
return false;
var legitimateVoicemailDomains = new[]
{
"ringcentral.com", "vonage.com", "grasshopper.com", "dialpad.com",
"8x8.com", "goto.com", "zoom.us", "microsoft.com", "office365.com"
};
var isFromLegitimate = legitimateVoicemailDomains.Any(d => fromDomain.EndsWith(d, StringComparison.OrdinalIgnoreCase)) ||
_config.Domains.Vendors.Keys.Any(v => fromDomain.Equals(v, StringComparison.OrdinalIgnoreCase));
var isSubdomainSpoof = CheckCompanySubdomainSpoof(fromDomain);
return !isFromLegitimate || isSubdomainSpoof;
}
private bool CheckFakeSystemNotification(string subject, string body, string fromDomain)
{
var text = (subject + " " + body).ToLowerInvariant();
var systemNotificationKeywords = _config.SystemNotificationKeywords.Count > 0
? _config.SystemNotificationKeywords
: new List<string> { "verify your email", "account suspended", "storage limit" };
var hasSystemKeyword = systemNotificationKeywords.Any(k => text.Contains(k, StringComparison.OrdinalIgnoreCase));
if (!hasSystemKeyword)
return false;
var legitimateSystemDomains = new[]
{
"microsoft.com", "office365.com", "google.com", "godaddy.com",
"intermedia.net", "hostpilot.com", "networksolutions.com", "namecheap.com",
"cloudflare.com", "amazon.com", "aws.amazon.com"
};
return !legitimateSystemDomains.Any(d => fromDomain.EndsWith(d, StringComparison.OrdinalIgnoreCase)) &&
!_config.Domains.Vendors.Keys.Any(v => fromDomain.Equals(v, StringComparison.OrdinalIgnoreCase));
}
}
@@ -0,0 +1,140 @@
namespace EmailSearch.SpamDetection;
public sealed class SpamDetectorConfig
{
public List<string> FreeDomains { get; set; } = new();
public List<string> BadTlds { get; set; } = new();
public List<string> BaitKeywords { get; set; } = new();
public DomainConfiguration Domains { get; set; } = new();
public SpamScoreWeights SpamScoreWeights { get; set; } = new();
public List<string> QuarantineKeywords { get; set; } = new();
public List<string> VoicemailKeywords { get; set; } = new();
public List<string> SystemNotificationKeywords { get; set; } = new();
public List<string> ColdEmailKeywords { get; set; } = new();
public List<string> FakeServiceDomains { get; set; } = new();
public static SpamDetectorConfig GetDefault()
{
return new SpamDetectorConfig
{
FreeDomains = new List<string>
{
"gmail.com", "outlook.com", "hotmail.com", "yahoo.com",
"icloud.com", "aol.com", "proton.me", "protonmail.com",
"live.com", "msn.com", "ymail.com", "mail.com"
},
BadTlds = new List<string>
{
"icu", "top", "click", "xyz", "mom", "quest", "work",
"shop", "rest", "tokyo", "pics", "zip", "com.br", "net",
"buzz", "cam", "link", "loan", "online", "site", "website"
},
BaitKeywords = new List<string>
{
// Financial
"invoice", "overdue", "wire", "zelle", "gift card", "payroll",
"remit", "ach", "payment", "past due", "bank transfer",
// Urgency/Action
"review & sign", "sign now", "action required", "urgent",
"verify", "confirm your", "suspended", "expire", "limited time",
// Account/System
"storage limit", "storage quota", "account", "password",
"security alert", "unusual activity", "locked",
// Messages/Notifications
"voice message", "voicemail", "fax", "document", "shared with you",
// Domain/SEO spam
"domain for sale", "premium domain", "seo", "website design",
// Cold sales
"setup request", "follow up", "checking in", "quick question"
},
Domains = new DomainConfiguration
{
Vendors = new Dictionary<string, VendorDomainInfo>(),
Trusted = new List<string>
{
"microsoft.com", "office365.com", "google.com", "amazon.com",
"apple.com", "github.com", "linkedin.com"
}
},
SpamScoreWeights = new SpamScoreWeights(),
QuarantineKeywords = new List<string>
{
"quarantine summary", "spam report", "quarantine folder",
"email quarantine", "quarantined email", "spam summary",
"junk summary", "blocked messages", "held messages"
},
VoicemailKeywords = new List<string>
{
"voicemail", "voice message", "voice mail", "audio message",
"new voicemail", "play voicemail", "missed call", "phone message"
},
SystemNotificationKeywords = new List<string>
{
"verify your email", "email verification", "verify now",
"confirm your email", "account suspended", "account locked",
"storage limit", "storage quota", "mailbox full",
"password expir", "credentials expir", "unusual activity",
"security alert", "suspicious activity", "action required"
},
ColdEmailKeywords = new List<string>
{
"seo services", "seo affordable", "search engine optimization",
"website ranking", "google ranking", "backlinks", "link building",
"website redesign", "web development", "web developer",
"website design", "graphic designer", "mobile app", "app development",
"reaching out", "hope this finds you", "i came across your",
"outsource", "offshore", "dedicated team", "cost-effective"
},
FakeServiceDomains = new List<string>
{
"voiceservicing.net", "audios.net", "voicemail.net",
"audioservices.net", "mailservicing.net", "emailservicing.net",
"securemail.net", "mailprotect.net", "docuservices.net"
}
};
}
}
public sealed class DomainConfiguration
{
public Dictionary<string, VendorDomainInfo> Vendors { get; set; } = new();
public List<string> Trusted { get; set; } = new();
}
public sealed class VendorDomainInfo
{
public int Reputation { get; set; }
public List<string> DisplayNamePatterns { get; set; } = new();
}
public sealed class SpamScoreWeights
{
public double SpfFail { get; set; } = 0.28;
public double DkimFail { get; set; } = 0.25;
public double DmarcFail { get; set; } = 0.30;
public double ReplyToDomainMismatch { get; set; } = 0.20;
public double DisplayImpersonation { get; set; } = 0.22;
public double UnicodeLookalike { get; set; } = 0.20;
public double HasUrl { get; set; } = 0.06;
public double UrlCountMultiplier { get; set; } = 0.02;
public double HasIpLink { get; set; } = 0.18;
public double UsesShortener { get; set; } = 0.12;
public double SuspiciousTld { get; set; } = 0.10;
public double HasTrackingPixel { get; set; } = 0.06;
public double HasAttachment { get; set; } = 0.06;
public double HasRiskyAttachment { get; set; } = 0.22;
public double KeywordBait { get; set; } = 0.22;
public double FreeMailboxWithUrl { get; set; } = 0.18;
public double FreeMailboxOnly { get; set; } = 0.08;
public double HasListUnsubscribe { get; set; } = -0.04;
public double ReputationMultiplier { get; set; } = 0.05;
public double UnknownDomain { get; set; } = 0.15;
public double CompanySubdomainSpoof { get; set; } = 0.45;
public double FakeQuarantineReport { get; set; } = 0.40;
public double HasZeroWidthChars { get; set; } = 0.35;
public double HasRandomRefId { get; set; } = 0.18;
public double HasTimestampInSubject { get; set; } = 0.15;
public double ColdEmailSolicitation { get; set; } = 0.30;
public double FakeVoicemailNotification { get; set; } = 0.42;
public double FakeSystemNotification { get; set; } = 0.38;
}
+57
View File
@@ -0,0 +1,57 @@
namespace EmailSearch.SpamDetection;
/// <summary>
/// Contains all extracted features from an email for spam analysis.
/// </summary>
public sealed class SpamFeatures
{
// Identity
public string DisplayName { get; set; } = "";
public string FromAddress { get; set; } = "";
public string FromDomain { get; set; } = "";
// Auth & headers
public bool SpfFail { get; set; }
public bool DkimFail { get; set; }
public bool DmarcFail { get; set; }
public bool ReplyToDomainMismatch { get; set; }
public bool HasListUnsub { get; set; }
// Impersonation / lookalikes
public bool DisplayImpersonation { get; set; }
public bool UnicodeLookalike { get; set; }
public bool GenericSenderName { get; set; }
public bool SubjectDomainImpersonation { get; set; }
// Links
public bool HasUrl { get; set; }
public int UrlCount { get; set; }
public bool HasIpLink { get; set; }
public bool UsesShortener { get; set; }
public bool SuspiciousTld { get; set; }
// Sender/domain traits
public bool FreeMailboxDomain { get; set; }
public bool UnknownDomain { get; set; }
public bool IsBlocklisted { get; set; }
public int SenderReputation { get; set; }
// Content/attachments
public bool HasTrackingPixel { get; set; }
public bool HasAttachment { get; set; }
public bool HasRiskyAttachment { get; set; }
public double AttachmentRiskScore { get; set; }
public bool KeywordBait { get; set; }
public bool SingleLinkOnly { get; set; }
public bool HasPlaceholderText { get; set; }
// Advanced patterns
public bool CompanySubdomainSpoof { get; set; }
public bool FakeQuarantineReport { get; set; }
public bool HasZeroWidthChars { get; set; }
public bool HasRandomRefId { get; set; }
public bool HasTimestampInSubject { get; set; }
public bool ColdEmailSolicitation { get; set; }
public bool FakeVoicemailNotification { get; set; }
public bool FakeSystemNotification { get; set; }
}
+31
View File
@@ -0,0 +1,31 @@
namespace EmailSearch.SpamDetection;
internal static class UrlAnalyzer
{
private static readonly HashSet<string> Shorteners = new(StringComparer.OrdinalIgnoreCase)
{
"bit.ly", "tinyurl.com", "t.co", "goo.gl", "is.gd", "buff.ly",
"ow.ly", "rb.gy", "rebrand.ly", "cutt.ly", "soo.gd", "tiny.cc",
"short.io", "bl.ink", "shorte.st", "clicky.me"
};
public static bool IsIpUrl(string url)
{
try
{
var host = new Uri(url).Host;
return System.Net.IPAddress.TryParse(host, out _);
}
catch { return false; }
}
public static bool IsShortener(string url)
{
try
{
var host = new Uri(url).Host.ToLowerInvariant();
return Shorteners.Any(s => host == s || host.EndsWith("." + s));
}
catch { return false; }
}
}
+94
View File
@@ -0,0 +1,94 @@
{
"freeDomains": [
"gmail.com", "outlook.com", "hotmail.com", "yahoo.com",
"icloud.com", "aol.com", "proton.me", "protonmail.com",
"live.com", "msn.com", "ymail.com", "mail.com"
],
"badTlds": [
"icu", "top", "click", "xyz", "mom", "quest", "work",
"shop", "rest", "tokyo", "pics", "zip", "com.br",
"buzz", "cam", "link", "loan", "online", "site", "website"
],
"baitKeywords": [
"invoice", "overdue", "wire", "zelle", "gift card", "payroll",
"remit", "ach", "payment", "past due", "bank transfer",
"review & sign", "sign now", "action required", "urgent",
"verify", "confirm your", "suspended", "expire", "limited time",
"storage limit", "storage quota", "account", "password",
"security alert", "unusual activity", "locked",
"voice message", "voicemail", "fax", "document", "shared with you",
"domain for sale", "premium domain", "seo", "website design",
"setup request", "follow up", "checking in", "quick question"
],
"domains": {
"vendors": {
"example-vendor.com": {
"reputation": 5,
"displayNamePatterns": ["example", "vendor"]
}
},
"trusted": [
"microsoft.com", "office365.com", "google.com", "amazon.com",
"apple.com", "github.com", "linkedin.com"
]
},
"quarantineKeywords": [
"quarantine summary", "spam report", "quarantine folder",
"email quarantine", "quarantined email", "spam summary",
"junk summary", "blocked messages", "held messages"
],
"voicemailKeywords": [
"voicemail", "voice message", "voice mail", "audio message",
"new voicemail", "play voicemail", "missed call", "phone message"
],
"systemNotificationKeywords": [
"verify your email", "email verification", "verify now",
"confirm your email", "account suspended", "account locked",
"storage limit", "storage quota", "mailbox full",
"password expir", "credentials expir", "unusual activity",
"security alert", "suspicious activity", "action required"
],
"coldEmailKeywords": [
"seo services", "seo affordable", "search engine optimization",
"website ranking", "google ranking", "backlinks", "link building",
"website redesign", "web development", "web developer",
"website design", "graphic designer", "mobile app", "app development",
"reaching out", "hope this finds you", "i came across your",
"outsource", "offshore", "dedicated team", "cost-effective"
],
"fakeServiceDomains": [
"voiceservicing.net", "audios.net", "voicemail.net",
"audioservices.net", "mailservicing.net", "emailservicing.net",
"securemail.net", "mailprotect.net", "docuservices.net"
],
"spamScoreWeights": {
"spfFail": 0.28,
"dkimFail": 0.25,
"dmarcFail": 0.30,
"replyToDomainMismatch": 0.20,
"displayImpersonation": 0.22,
"unicodeLookalike": 0.20,
"hasUrl": 0.06,
"urlCountMultiplier": 0.02,
"hasIpLink": 0.18,
"usesShortener": 0.12,
"suspiciousTld": 0.10,
"hasTrackingPixel": 0.06,
"hasAttachment": 0.06,
"hasRiskyAttachment": 0.22,
"keywordBait": 0.22,
"freeMailboxWithUrl": 0.18,
"freeMailboxOnly": 0.08,
"hasListUnsubscribe": -0.04,
"reputationMultiplier": 0.05,
"unknownDomain": 0.15,
"companySubdomainSpoof": 0.45,
"fakeQuarantineReport": 0.40,
"hasZeroWidthChars": 0.35,
"hasRandomRefId": 0.18,
"hasTimestampInSubject": 0.15,
"coldEmailSolicitation": 0.30,
"fakeVoicemailNotification": 0.42,
"fakeSystemNotification": 0.38
}
}