From 0bddb21f8c28651c01af31677500602bff615538 Mon Sep 17 00:00:00 2001 From: AJ Isaacs Date: Thu, 19 Feb 2026 13:11:11 -0500 Subject: [PATCH] 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 --- EmailSearch/Agents/AIAgentRunner.cs | 100 ---- EmailSearch/Agents/AIEmailAgent.cs | 685 ------------------------ EmailSearch/Agents/AgentRunner.cs | 101 ---- EmailSearch/Agents/EmailWatcherAgent.cs | 304 ----------- EmailSearch/EmailWatcherConfig.json | 22 - EmailSearch/Program.cs | 32 +- 6 files changed, 7 insertions(+), 1237 deletions(-) delete mode 100644 EmailSearch/Agents/AIAgentRunner.cs delete mode 100644 EmailSearch/Agents/AIEmailAgent.cs delete mode 100644 EmailSearch/Agents/AgentRunner.cs delete mode 100644 EmailSearch/Agents/EmailWatcherAgent.cs delete mode 100644 EmailSearch/EmailWatcherConfig.json diff --git a/EmailSearch/Agents/AIAgentRunner.cs b/EmailSearch/Agents/AIAgentRunner.cs deleted file mode 100644 index 7d21f06..0000000 --- a/EmailSearch/Agents/AIAgentRunner.cs +++ /dev/null @@ -1,100 +0,0 @@ -using System.Text.Json; - -namespace EmailSearch.Agents; - -/// -/// Entry point for running the AI Email Agent standalone. -/// -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(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 - { - "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; - } -} diff --git a/EmailSearch/Agents/AIEmailAgent.cs b/EmailSearch/Agents/AIEmailAgent.cs deleted file mode 100644 index 2abccec..0000000 --- a/EmailSearch/Agents/AIEmailAgent.cs +++ /dev/null @@ -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; - -/// -/// 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. -/// -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 _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 DefineTools() - { - return new List - { - 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}"); - } - }); - } - - /// - /// The core agentic loop - sends email to Claude, executes tool calls, continues until done. - /// - 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 - { - 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(); - - 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().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(); - 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 CallClaudeAsync(string systemPrompt, List 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(responseJson, new JsonSerializerOptions - { - PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower - }); - } - catch (Exception ex) - { - Log($"[API Exception] {ex.Message}"); - return null; - } - } - - private async Task 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 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 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 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; } -} diff --git a/EmailSearch/Agents/AgentRunner.cs b/EmailSearch/Agents/AgentRunner.cs deleted file mode 100644 index ff008f9..0000000 --- a/EmailSearch/Agents/AgentRunner.cs +++ /dev/null @@ -1,101 +0,0 @@ -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Hosting; -using System.Text.Json; - -namespace EmailSearch.Agents; - -/// -/// Standalone runner for the EmailWatcher agent. -/// Can be invoked from CLI or integrated into a host. -/// -public static class AgentRunner -{ - /// - /// Run the email watcher agent as a standalone process. - /// - 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); - } - - /// - /// Build a host with the email watcher agent for integration with other services. - /// - public static IHostBuilder CreateHostBuilder(string? configPath = null) - { - var config = LoadConfig(configPath); - - return Host.CreateDefaultBuilder() - .ConfigureServices(services => - { - services.AddSingleton(config); - services.AddHostedService(); - }); - } - - 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(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() - }; - } -} diff --git a/EmailSearch/Agents/EmailWatcherAgent.cs b/EmailSearch/Agents/EmailWatcherAgent.cs deleted file mode 100644 index 0887f38..0000000 --- a/EmailSearch/Agents/EmailWatcherAgent.cs +++ /dev/null @@ -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; - -/// -/// Background agent that watches for new emails and sends notifications -/// when interesting emails arrive (e.g., high-priority, from specific senders, or spam detected). -/// -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 WatchedSenders { get; set; } = new(); // Email addresses or names - public List WatchedKeywords { get; set; } = new(); // Subject keywords -} diff --git a/EmailSearch/EmailWatcherConfig.json b/EmailSearch/EmailWatcherConfig.json deleted file mode 100644 index e94e544..0000000 --- a/EmailSearch/EmailWatcherConfig.json +++ /dev/null @@ -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" - ] -} diff --git a/EmailSearch/Program.cs b/EmailSearch/Program.cs index 7c713f6..d98069c 100644 --- a/EmailSearch/Program.cs +++ b/EmailSearch/Program.cs @@ -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(); - await AIAgentRunner.RunAsync(cts.Token); -} -else -{ - // Run as MCP Server (default) - var builder = Host.CreateApplicationBuilder(args); - builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithTools(); - - var app = builder.Build(); - await app.RunAsync(); -} +var app = builder.Build(); +await app.RunAsync();