Compare commits
4 Commits
09181d0991
...
c72e81601c
| Author | SHA1 | Date | |
|---|---|---|---|
| c72e81601c | |||
| f59b610d0b | |||
| 489cb8f657 | |||
| 51858cbd01 |
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>();
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user