refactor: remove agent system, keep MCP server only

The AI email agent and email watcher agent added unnecessary complexity
and external dependencies (Anthropic API, Gotify). The MCP server alone
provides all needed email functionality through Claude Code.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-19 13:11:11 -05:00
parent c72e81601c
commit 0bddb21f8c
6 changed files with 7 additions and 1237 deletions

View File

@@ -1,100 +0,0 @@
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;
}
}

View File

@@ -1,685 +0,0 @@
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; }
}

View File

@@ -1,101 +0,0 @@
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()
};
}
}

View File

@@ -1,304 +0,0 @@
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
}

View File

@@ -1,22 +0,0 @@
{
"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"
]
}

View File

@@ -1,30 +1,12 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using ModelContextProtocol.Server;
using EmailSearch.Agents;
// Check for agent mode via command line args
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();
};
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<EmailSearchTools>();
await AIAgentRunner.RunAsync(cts.Token);
}
else
{
// Run as MCP Server (default)
var builder = Host.CreateApplicationBuilder(args);
builder.Services
.AddMcpServer()
.WithStdioServerTransport()
.WithTools<EmailSearchTools>();
var app = builder.Build();
await app.RunAsync();
}
var app = builder.Build();
await app.RunAsync();