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();