diff --git a/EmailSearch/AIAgentConfig.json b/EmailSearch/AIAgentConfig.json new file mode 100644 index 0000000..aacf5f2 --- /dev/null +++ b/EmailSearch/AIAgentConfig.json @@ -0,0 +1,17 @@ +{ + "anthropicApiKey": "", + "model": "claude-sonnet-4-20250514", + "watchFolder": "Inbox", + + "gotifyUrl": "http://gotify.local:8080", + "gotifyToken": "YOUR_GOTIFY_APP_TOKEN", + + "userPriorities": [ + "Emails from my boss or direct team members", + "Urgent requests that need immediate attention", + "Customer inquiries and support tickets", + "Meeting changes and calendar updates", + "Security alerts from legitimate services (Microsoft, Google, etc.)", + "Invoices and payment confirmations from known vendors" + ] +} diff --git a/EmailSearch/Agents/AIAgentRunner.cs b/EmailSearch/Agents/AIAgentRunner.cs new file mode 100644 index 0000000..7d21f06 --- /dev/null +++ b/EmailSearch/Agents/AIAgentRunner.cs @@ -0,0 +1,100 @@ +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 new file mode 100644 index 0000000..2abccec --- /dev/null +++ b/EmailSearch/Agents/AIEmailAgent.cs @@ -0,0 +1,685 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.Hosting; +using NetOffice.OutlookApi; +using NetOffice.OutlookApi.Enums; +using EmailSearch.SpamDetection; +using OutlookApp = NetOffice.OutlookApi.Application; +using Exception = System.Exception; + +namespace EmailSearch.Agents; + +/// +/// 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 new file mode 100644 index 0000000..ff008f9 --- /dev/null +++ b/EmailSearch/Agents/AgentRunner.cs @@ -0,0 +1,101 @@ +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 new file mode 100644 index 0000000..0887f38 --- /dev/null +++ b/EmailSearch/Agents/EmailWatcherAgent.cs @@ -0,0 +1,304 @@ +using Microsoft.Extensions.Hosting; +using System.Net.Http.Json; +using NetOffice.OutlookApi; +using NetOffice.OutlookApi.Enums; +using EmailSearch.SpamDetection; +using OutlookApp = NetOffice.OutlookApi.Application; +using Exception = System.Exception; + +namespace EmailSearch.Agents; + +/// +/// 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 new file mode 100644 index 0000000..e94e544 --- /dev/null +++ b/EmailSearch/EmailWatcherConfig.json @@ -0,0 +1,22 @@ +{ + "watchFolder": "Inbox", + + "gotifyUrl": "http://gotify.local:8080", + "gotifyToken": "YOUR_GOTIFY_APP_TOKEN", + "webhookUrl": null, + + "notifyOnHighImportance": true, + "notifyOnSpam": true, + "spamThreshold": 0.7, + "notifyOnAll": false, + + "watchedSenders": [ + "boss@company.com", + "important-client.com" + ], + "watchedKeywords": [ + "urgent", + "invoice", + "payment due" + ] +} diff --git a/EmailSearch/Program.cs b/EmailSearch/Program.cs index d98069c..7c713f6 100644 --- a/EmailSearch/Program.cs +++ b/EmailSearch/Program.cs @@ -1,12 +1,30 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using ModelContextProtocol.Server; +using EmailSearch.Agents; -var builder = Host.CreateApplicationBuilder(args); -builder.Services - .AddMcpServer() - .WithStdioServerTransport() - .WithTools(); +// 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 app = builder.Build(); -await app.RunAsync(); + 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(); +}