Compare commits
3 Commits
c72e81601c
...
961155f887
| Author | SHA1 | Date | |
|---|---|---|---|
| 961155f887 | |||
| 27fc3b65e9 | |||
| 0bddb21f8c |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -31,3 +31,4 @@ _ReSharper*/
|
||||
.idea/
|
||||
#Nuget packages folder
|
||||
packages/
|
||||
/.claude/
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EmailSearch.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// Entry point for running the AI Email Agent standalone.
|
||||
/// </summary>
|
||||
public static class AIAgentRunner
|
||||
{
|
||||
public static async Task RunAsync(CancellationToken cancellationToken = default)
|
||||
{
|
||||
Console.WriteLine("╔═══════════════════════════════════════════════════════════╗");
|
||||
Console.WriteLine("║ AI EMAIL AGENT - Powered by Claude ║");
|
||||
Console.WriteLine("╚═══════════════════════════════════════════════════════════╝");
|
||||
Console.WriteLine();
|
||||
|
||||
var config = LoadConfig();
|
||||
|
||||
if (string.IsNullOrEmpty(config.AnthropicApiKey))
|
||||
{
|
||||
Console.WriteLine("ERROR: Anthropic API key not configured!");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Set your API key via:");
|
||||
Console.WriteLine(" 1. Environment variable: ANTHROPIC_API_KEY");
|
||||
Console.WriteLine(" 2. Config file: AIAgentConfig.json");
|
||||
Console.WriteLine();
|
||||
return;
|
||||
}
|
||||
|
||||
Console.WriteLine($"Model: {config.Model}");
|
||||
Console.WriteLine($"Watching: {config.WatchFolder}");
|
||||
Console.WriteLine($"Gotify: {(string.IsNullOrEmpty(config.GotifyUrl) ? "(not configured)" : config.GotifyUrl)}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("User priorities:");
|
||||
foreach (var p in config.UserPriorities)
|
||||
Console.WriteLine($" • {p}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press Ctrl+C to stop...");
|
||||
Console.WriteLine();
|
||||
|
||||
using var agent = new AIEmailAgent(config);
|
||||
await agent.StartAsync(cancellationToken);
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) { }
|
||||
|
||||
await agent.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
private static AIAgentConfig LoadConfig()
|
||||
{
|
||||
var configPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "AIAgentConfig.json");
|
||||
|
||||
AIAgentConfig? config = null;
|
||||
|
||||
if (File.Exists(configPath))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(configPath);
|
||||
config = JsonSerializer.Deserialize<AIAgentConfig>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to load config: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
config ??= new AIAgentConfig();
|
||||
|
||||
// Override with environment variables if set
|
||||
config.AnthropicApiKey = Environment.GetEnvironmentVariable("ANTHROPIC_API_KEY")
|
||||
?? config.AnthropicApiKey;
|
||||
config.GotifyUrl = Environment.GetEnvironmentVariable("GOTIFY_URL")
|
||||
?? config.GotifyUrl;
|
||||
config.GotifyToken = Environment.GetEnvironmentVariable("GOTIFY_TOKEN")
|
||||
?? config.GotifyToken;
|
||||
|
||||
// Default priorities if none configured
|
||||
if (config.UserPriorities.Count == 0)
|
||||
{
|
||||
config.UserPriorities = new List<string>
|
||||
{
|
||||
"Emails from my boss or direct team members",
|
||||
"Urgent or time-sensitive requests",
|
||||
"Meeting changes and calendar updates",
|
||||
"Security alerts from legitimate services",
|
||||
"Customer inquiries that need quick response"
|
||||
};
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
}
|
||||
@@ -1,685 +0,0 @@
|
||||
using System.Net.Http.Headers;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using NetOffice.OutlookApi;
|
||||
using NetOffice.OutlookApi.Enums;
|
||||
using EmailSearch.SpamDetection;
|
||||
using OutlookApp = NetOffice.OutlookApi.Application;
|
||||
using Exception = System.Exception;
|
||||
|
||||
namespace EmailSearch.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// A true AI Agent that uses Claude to reason about emails and decide what actions to take.
|
||||
/// The LLM is in the decision loop - it evaluates each email and chooses actions dynamically.
|
||||
/// </summary>
|
||||
public class AIEmailAgent : BackgroundService
|
||||
{
|
||||
private readonly AIAgentConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SpamDetector _spamDetector;
|
||||
private OutlookApp? _outlookApp;
|
||||
private _NameSpace? _namespace;
|
||||
private MAPIFolder? _watchFolder;
|
||||
private Items? _items;
|
||||
|
||||
// Tools the agent can use
|
||||
private readonly List<AgentTool> _tools;
|
||||
|
||||
public AIEmailAgent(AIAgentConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_httpClient = new HttpClient();
|
||||
_httpClient.DefaultRequestHeaders.Add("x-api-key", config.AnthropicApiKey);
|
||||
_httpClient.DefaultRequestHeaders.Add("anthropic-version", "2023-06-01");
|
||||
_spamDetector = new SpamDetector();
|
||||
|
||||
// Define tools the agent can use
|
||||
_tools = DefineTools();
|
||||
}
|
||||
|
||||
private List<AgentTool> DefineTools()
|
||||
{
|
||||
return new List<AgentTool>
|
||||
{
|
||||
new AgentTool
|
||||
{
|
||||
Name = "send_notification",
|
||||
Description = "Send a push notification to the user's phone via Gotify. Use for important emails that need immediate attention.",
|
||||
InputSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
title = new { type = "string", description = "Short notification title" },
|
||||
message = new { type = "string", description = "Notification body with key details" },
|
||||
priority = new { type = "integer", description = "1-10, where 10 is most urgent. Use 8+ for truly urgent, 5-7 for important, 1-4 for informational" }
|
||||
},
|
||||
required = new[] { "title", "message", "priority" }
|
||||
}
|
||||
},
|
||||
new AgentTool
|
||||
{
|
||||
Name = "move_to_folder",
|
||||
Description = "Move the email to a specific Outlook folder. Use for organizing emails (e.g., move spam to Junk, receipts to Archive).",
|
||||
InputSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
folder = new { type = "string", description = "Target folder: Junk, Archive, or any custom folder name" },
|
||||
reason = new { type = "string", description = "Brief reason for moving" }
|
||||
},
|
||||
required = new[] { "folder", "reason" }
|
||||
}
|
||||
},
|
||||
new AgentTool
|
||||
{
|
||||
Name = "flag_email",
|
||||
Description = "Flag the email for follow-up with a category. Use when the email needs future attention but not immediate notification.",
|
||||
InputSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
category = new { type = "string", description = "Category to apply: 'Follow Up', 'Review', 'Waiting', 'Important'" },
|
||||
reason = new { type = "string", description = "Why this needs follow-up" }
|
||||
},
|
||||
required = new[] { "category" }
|
||||
}
|
||||
},
|
||||
new AgentTool
|
||||
{
|
||||
Name = "log_observation",
|
||||
Description = "Log an observation about the email without taking action. Use when the email is noted but requires no action.",
|
||||
InputSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
observation = new { type = "string", description = "What you observed about this email" }
|
||||
},
|
||||
required = new[] { "observation" }
|
||||
}
|
||||
},
|
||||
new AgentTool
|
||||
{
|
||||
Name = "analyze_spam",
|
||||
Description = "Run detailed spam analysis on the email to get risk score and red flags. Use when you're unsure if an email is legitimate.",
|
||||
InputSchema = new
|
||||
{
|
||||
type = "object",
|
||||
properties = new
|
||||
{
|
||||
include_details = new { type = "boolean", description = "Include detailed feature breakdown" }
|
||||
},
|
||||
required = new string[] { }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Log("AI Email Agent starting...");
|
||||
Log($"Model: {_config.Model}");
|
||||
Log($"Watching: {_config.WatchFolder}");
|
||||
|
||||
try
|
||||
{
|
||||
InitializeOutlook();
|
||||
Log("Connected to Outlook. AI Agent ready and watching for emails...");
|
||||
Log("");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeOutlook()
|
||||
{
|
||||
_outlookApp = new OutlookApp();
|
||||
_namespace = _outlookApp.GetNamespace("MAPI");
|
||||
|
||||
_watchFolder = _config.WatchFolder.Equals("Inbox", StringComparison.OrdinalIgnoreCase)
|
||||
? _namespace.GetDefaultFolder(OlDefaultFolders.olFolderInbox)
|
||||
: FindFolder(_namespace, _config.WatchFolder);
|
||||
|
||||
if (_watchFolder == null)
|
||||
throw new Exception($"Folder '{_config.WatchFolder}' not found");
|
||||
|
||||
_items = (Items)_watchFolder.Items;
|
||||
_items.ItemAddEvent += OnNewEmailReceived;
|
||||
}
|
||||
|
||||
private void OnNewEmailReceived(object item)
|
||||
{
|
||||
if (item is not MailItem mail)
|
||||
return;
|
||||
|
||||
// Run agent processing on a background thread
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
await ProcessEmailWithAgent(mail);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"Agent error: {ex.Message}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The core agentic loop - sends email to Claude, executes tool calls, continues until done.
|
||||
/// </summary>
|
||||
private async Task ProcessEmailWithAgent(MailItem mail)
|
||||
{
|
||||
var subject = mail.Subject ?? "(No Subject)";
|
||||
var sender = mail.SenderName ?? mail.SenderEmailAddress ?? "Unknown";
|
||||
var senderEmail = mail.SenderEmailAddress ?? "";
|
||||
var body = TruncateBody(mail.Body ?? "", 2000);
|
||||
var receivedTime = mail.ReceivedTime;
|
||||
|
||||
Log($"");
|
||||
Log($"═══════════════════════════════════════════════════════════");
|
||||
Log($"NEW EMAIL: {subject}");
|
||||
Log($"From: {sender} <{senderEmail}>");
|
||||
Log($"Time: {receivedTime}");
|
||||
Log($"═══════════════════════════════════════════════════════════");
|
||||
|
||||
// Build context for the agent
|
||||
var systemPrompt = BuildSystemPrompt();
|
||||
var userMessage = BuildEmailContext(mail, subject, sender, senderEmail, body);
|
||||
|
||||
var messages = new List<AgentMessage>
|
||||
{
|
||||
new AgentMessage { Role = "user", Content = userMessage }
|
||||
};
|
||||
|
||||
// Agentic loop - keep going until Claude stops calling tools
|
||||
var iteration = 0;
|
||||
const int maxIterations = 5;
|
||||
|
||||
while (iteration < maxIterations)
|
||||
{
|
||||
iteration++;
|
||||
Log($"");
|
||||
Log($"[Agent thinking... iteration {iteration}]");
|
||||
|
||||
var response = await CallClaudeAsync(systemPrompt, messages);
|
||||
|
||||
if (response == null)
|
||||
{
|
||||
Log("[Agent] Failed to get response from Claude");
|
||||
break;
|
||||
}
|
||||
|
||||
// Process the response
|
||||
var hasToolUse = false;
|
||||
var toolResults = new List<ToolResultContent>();
|
||||
|
||||
foreach (var content in response.Content)
|
||||
{
|
||||
if (content.Type == "text" && !string.IsNullOrWhiteSpace(content.Text))
|
||||
{
|
||||
Log($"[Agent] {content.Text}");
|
||||
}
|
||||
else if (content.Type == "tool_use" && content.Input.HasValue)
|
||||
{
|
||||
hasToolUse = true;
|
||||
Log($"[Agent calling tool: {content.Name}]");
|
||||
|
||||
// Execute the tool
|
||||
var result = await ExecuteToolAsync(content.Name!, content.Input.Value, mail);
|
||||
toolResults.Add(new ToolResultContent
|
||||
{
|
||||
Type = "tool_result",
|
||||
ToolUseId = content.Id!,
|
||||
Content = result
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// If no tool calls, agent is done
|
||||
if (!hasToolUse)
|
||||
{
|
||||
Log($"[Agent complete]");
|
||||
break;
|
||||
}
|
||||
|
||||
// Add assistant response and tool results to continue the conversation
|
||||
messages.Add(new AgentMessage
|
||||
{
|
||||
Role = "assistant",
|
||||
Content = response.Content
|
||||
});
|
||||
|
||||
messages.Add(new AgentMessage
|
||||
{
|
||||
Role = "user",
|
||||
Content = toolResults.Cast<object>().ToList()
|
||||
});
|
||||
}
|
||||
|
||||
Log($"───────────────────────────────────────────────────────────");
|
||||
}
|
||||
|
||||
private string BuildSystemPrompt()
|
||||
{
|
||||
var priorities = _config.UserPriorities.Count > 0
|
||||
? string.Join("\n", _config.UserPriorities.Select(p => $"- {p}"))
|
||||
: "- No specific priorities configured";
|
||||
|
||||
return $"""
|
||||
You are an AI email assistant that monitors incoming emails and takes appropriate actions.
|
||||
|
||||
## Your Role
|
||||
You evaluate each incoming email and decide what action(s) to take. You have tools available
|
||||
to send notifications, organize emails, flag for follow-up, or simply log observations.
|
||||
|
||||
## User's Priorities and Interests
|
||||
The user has indicated these are important to them:
|
||||
{priorities}
|
||||
|
||||
## Decision Guidelines
|
||||
|
||||
**Send notification (priority 8-10)** when:
|
||||
- Email is from someone the user specifically cares about
|
||||
- Contains urgent or time-sensitive information
|
||||
- Requires immediate action or response
|
||||
- Security alerts or account issues from legitimate sources
|
||||
|
||||
**Send notification (priority 5-7)** when:
|
||||
- Important but not urgent information
|
||||
- Updates the user would want to know about
|
||||
- Meeting changes, calendar updates
|
||||
|
||||
**Flag for follow-up** when:
|
||||
- Email needs a response but not immediately
|
||||
- Contains tasks or action items
|
||||
- Requires review when user has time
|
||||
|
||||
**Move to folder** when:
|
||||
- Clear spam or phishing (move to Junk)
|
||||
- Newsletters/marketing the user didn't ask for
|
||||
- Receipts or automated confirmations (move to Archive)
|
||||
|
||||
**Log observation only** when:
|
||||
- Routine email that needs no action
|
||||
- Already handled by other actions
|
||||
- Informational with no follow-up needed
|
||||
|
||||
## Important
|
||||
- Be concise in your reasoning
|
||||
- You can call multiple tools if appropriate
|
||||
- If unsure about legitimacy, use analyze_spam first
|
||||
- Don't over-notify - respect the user's attention
|
||||
- Consider the sender, subject, and content together
|
||||
|
||||
Current time: {DateTime.Now:yyyy-MM-dd HH:mm}
|
||||
""";
|
||||
}
|
||||
|
||||
private string BuildEmailContext(MailItem mail, string subject, string sender, string senderEmail, string body)
|
||||
{
|
||||
var attachments = new List<string>();
|
||||
if (mail.Attachments != null)
|
||||
{
|
||||
foreach (var att in mail.Attachments)
|
||||
{
|
||||
if (att is Attachment a)
|
||||
attachments.Add(a.FileName);
|
||||
}
|
||||
}
|
||||
|
||||
var importance = mail.Importance switch
|
||||
{
|
||||
OlImportance.olImportanceHigh => "HIGH",
|
||||
OlImportance.olImportanceLow => "Low",
|
||||
_ => "Normal"
|
||||
};
|
||||
|
||||
return $"""
|
||||
A new email has arrived. Please evaluate it and decide what action(s) to take.
|
||||
|
||||
**From:** {sender} <{senderEmail}>
|
||||
**Subject:** {subject}
|
||||
**Importance:** {importance}
|
||||
**Received:** {mail.ReceivedTime:yyyy-MM-dd HH:mm}
|
||||
**Attachments:** {(attachments.Count > 0 ? string.Join(", ", attachments) : "None")}
|
||||
|
||||
**Body:**
|
||||
{body}
|
||||
|
||||
What should I do with this email?
|
||||
""";
|
||||
}
|
||||
|
||||
private async Task<ClaudeResponse?> CallClaudeAsync(string systemPrompt, List<AgentMessage> messages)
|
||||
{
|
||||
var request = new
|
||||
{
|
||||
model = _config.Model,
|
||||
max_tokens = 1024,
|
||||
system = systemPrompt,
|
||||
tools = _tools.Select(t => new
|
||||
{
|
||||
name = t.Name,
|
||||
description = t.Description,
|
||||
input_schema = t.InputSchema
|
||||
}),
|
||||
messages = messages.Select(m => new
|
||||
{
|
||||
role = m.Role,
|
||||
content = m.Content
|
||||
})
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(request, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
|
||||
});
|
||||
|
||||
var content = new StringContent(json, Encoding.UTF8, "application/json");
|
||||
|
||||
try
|
||||
{
|
||||
var response = await _httpClient.PostAsync("https://api.anthropic.com/v1/messages", content);
|
||||
var responseJson = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Log($"[API Error] {response.StatusCode}: {responseJson}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<ClaudeResponse>(responseJson, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"[API Exception] {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteToolAsync(string toolName, JsonElement input, MailItem mail)
|
||||
{
|
||||
try
|
||||
{
|
||||
switch (toolName)
|
||||
{
|
||||
case "send_notification":
|
||||
return await ExecuteSendNotification(input);
|
||||
|
||||
case "move_to_folder":
|
||||
return ExecuteMoveToFolder(input, mail);
|
||||
|
||||
case "flag_email":
|
||||
return ExecuteFlagEmail(input, mail);
|
||||
|
||||
case "log_observation":
|
||||
return ExecuteLogObservation(input);
|
||||
|
||||
case "analyze_spam":
|
||||
return ExecuteAnalyzeSpam(input, mail);
|
||||
|
||||
default:
|
||||
return $"Unknown tool: {toolName}";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Tool error: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string> ExecuteSendNotification(JsonElement input)
|
||||
{
|
||||
var title = input.GetProperty("title").GetString() ?? "Notification";
|
||||
var message = input.GetProperty("message").GetString() ?? "";
|
||||
var priority = input.TryGetProperty("priority", out var p) ? p.GetInt32() : 5;
|
||||
|
||||
Log($" → Sending notification: [{priority}] {title}");
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.GotifyUrl) && !string.IsNullOrEmpty(_config.GotifyToken))
|
||||
{
|
||||
var url = $"{_config.GotifyUrl.TrimEnd('/')}/message?token={_config.GotifyToken}";
|
||||
var payload = new { title, message, priority };
|
||||
|
||||
var response = await _httpClient.PostAsync(url,
|
||||
new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"));
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return $"Notification sent successfully (priority {priority})";
|
||||
else
|
||||
return $"Failed to send notification: {response.StatusCode}";
|
||||
}
|
||||
|
||||
// Fallback: just log it
|
||||
return $"Notification logged (Gotify not configured): [{priority}] {title} - {message}";
|
||||
}
|
||||
|
||||
private string ExecuteMoveToFolder(JsonElement input, MailItem mail)
|
||||
{
|
||||
var folder = input.GetProperty("folder").GetString() ?? "Junk";
|
||||
var reason = input.TryGetProperty("reason", out var r) ? r.GetString() : "No reason provided";
|
||||
|
||||
Log($" → Moving to {folder}: {reason}");
|
||||
|
||||
try
|
||||
{
|
||||
MAPIFolder? targetFolder = null;
|
||||
|
||||
if (folder.Equals("Junk", StringComparison.OrdinalIgnoreCase))
|
||||
targetFolder = _namespace!.GetDefaultFolder(OlDefaultFolders.olFolderJunk);
|
||||
else if (folder.Equals("Deleted", StringComparison.OrdinalIgnoreCase) ||
|
||||
folder.Equals("Trash", StringComparison.OrdinalIgnoreCase))
|
||||
targetFolder = _namespace!.GetDefaultFolder(OlDefaultFolders.olFolderDeletedItems);
|
||||
else
|
||||
targetFolder = FindFolder(_namespace!, folder);
|
||||
|
||||
if (targetFolder != null)
|
||||
{
|
||||
mail.Move(targetFolder);
|
||||
return $"Email moved to {folder}";
|
||||
}
|
||||
else
|
||||
{
|
||||
return $"Folder '{folder}' not found";
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Failed to move email: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private string ExecuteFlagEmail(JsonElement input, MailItem mail)
|
||||
{
|
||||
var category = input.GetProperty("category").GetString() ?? "Follow Up";
|
||||
var reason = input.TryGetProperty("reason", out var r) ? r.GetString() : "";
|
||||
|
||||
Log($" → Flagging as '{category}': {reason}");
|
||||
|
||||
try
|
||||
{
|
||||
// Add category
|
||||
if (string.IsNullOrEmpty(mail.Categories))
|
||||
mail.Categories = category;
|
||||
else if (!mail.Categories.Contains(category))
|
||||
mail.Categories += ", " + category;
|
||||
|
||||
// Set flag
|
||||
mail.FlagStatus = OlFlagStatus.olFlagMarked;
|
||||
mail.Save();
|
||||
|
||||
return $"Email flagged with category '{category}'";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return $"Failed to flag email: {ex.Message}";
|
||||
}
|
||||
}
|
||||
|
||||
private string ExecuteLogObservation(JsonElement input)
|
||||
{
|
||||
var observation = input.GetProperty("observation").GetString() ?? "";
|
||||
Log($" → Observation: {observation}");
|
||||
return "Observation logged";
|
||||
}
|
||||
|
||||
private string ExecuteAnalyzeSpam(JsonElement input, MailItem mail)
|
||||
{
|
||||
var includeDetails = input.TryGetProperty("include_details", out var d) && d.GetBoolean();
|
||||
|
||||
var result = _spamDetector.Analyze(mail);
|
||||
|
||||
var response = new StringBuilder();
|
||||
response.AppendLine($"Spam Score: {result.FinalScore:P0} ({result.SpamLikelihood})");
|
||||
response.AppendLine($"Predicted Spam: {(result.PredictedSpam ? "YES" : "No")}");
|
||||
|
||||
if (result.RedFlags.Count > 0)
|
||||
{
|
||||
response.AppendLine($"Red Flags: {string.Join("; ", result.RedFlags)}");
|
||||
}
|
||||
|
||||
if (includeDetails && result.Features != null)
|
||||
{
|
||||
response.AppendLine();
|
||||
response.AppendLine("Details:");
|
||||
response.AppendLine($" Domain: {result.Features.FromDomain}");
|
||||
response.AppendLine($" Free email: {result.Features.FreeMailboxDomain}");
|
||||
response.AppendLine($" Unknown domain: {result.Features.UnknownDomain}");
|
||||
response.AppendLine($" Auth failures: SPF={result.Features.SpfFail}, DKIM={result.Features.DkimFail}");
|
||||
response.AppendLine($" URLs: {result.Features.UrlCount}, Shortener: {result.Features.UsesShortener}");
|
||||
response.AppendLine($" Attachment risk: {result.Features.AttachmentRiskScore:P0}");
|
||||
}
|
||||
|
||||
Log($" → Spam analysis: {result.FinalScore:P0} - {result.SpamLikelihood}");
|
||||
return response.ToString();
|
||||
}
|
||||
|
||||
private static MAPIFolder? FindFolder(_NameSpace ns, string folderName)
|
||||
{
|
||||
foreach (var store in ns.Stores)
|
||||
{
|
||||
if (store is Store s)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootFolder = s.GetRootFolder() as MAPIFolder;
|
||||
if (rootFolder != null)
|
||||
{
|
||||
var found = SearchFolderRecursive(rootFolder, folderName);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MAPIFolder? SearchFolderRecursive(MAPIFolder parent, string folderName)
|
||||
{
|
||||
foreach (var subfolder in parent.Folders)
|
||||
{
|
||||
if (subfolder is MAPIFolder folder)
|
||||
{
|
||||
if (folder.Name.Equals(folderName, StringComparison.OrdinalIgnoreCase))
|
||||
return folder;
|
||||
var found = SearchFolderRecursive(folder, folderName);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string TruncateBody(string body, int maxLength)
|
||||
{
|
||||
if (body.Length <= maxLength)
|
||||
return body;
|
||||
return body.Substring(0, maxLength) + "\n[... truncated ...]";
|
||||
}
|
||||
|
||||
private void Log(string message)
|
||||
{
|
||||
Console.WriteLine($"{DateTime.Now:HH:mm:ss} {message}");
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
_items?.Dispose();
|
||||
_watchFolder?.Dispose();
|
||||
_namespace?.Dispose();
|
||||
_outlookApp?.Dispose();
|
||||
_httpClient.Dispose();
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
// Configuration
|
||||
public class AIAgentConfig
|
||||
{
|
||||
public string AnthropicApiKey { get; set; } = "";
|
||||
public string Model { get; set; } = "claude-sonnet-4-20250514";
|
||||
public string WatchFolder { get; set; } = "Inbox";
|
||||
public string? GotifyUrl { get; set; }
|
||||
public string? GotifyToken { get; set; }
|
||||
public List<string> UserPriorities { get; set; } = new();
|
||||
}
|
||||
|
||||
// Claude API types
|
||||
public class AgentTool
|
||||
{
|
||||
public string Name { get; set; } = "";
|
||||
public string Description { get; set; } = "";
|
||||
public object InputSchema { get; set; } = new { };
|
||||
}
|
||||
|
||||
public class AgentMessage
|
||||
{
|
||||
public string Role { get; set; } = "";
|
||||
public object Content { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ToolResultContent
|
||||
{
|
||||
public string Type { get; set; } = "tool_result";
|
||||
public string ToolUseId { get; set; } = "";
|
||||
public string Content { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ClaudeResponse
|
||||
{
|
||||
public string Id { get; set; } = "";
|
||||
public string Type { get; set; } = "";
|
||||
public string Role { get; set; } = "";
|
||||
public List<ContentBlock> Content { get; set; } = new();
|
||||
public string StopReason { get; set; } = "";
|
||||
}
|
||||
|
||||
public class ContentBlock
|
||||
{
|
||||
public string Type { get; set; } = "";
|
||||
public string? Text { get; set; }
|
||||
public string? Id { get; set; }
|
||||
public string? Name { get; set; }
|
||||
public JsonElement? Input { get; set; }
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace EmailSearch.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone runner for the EmailWatcher agent.
|
||||
/// Can be invoked from CLI or integrated into a host.
|
||||
/// </summary>
|
||||
public static class AgentRunner
|
||||
{
|
||||
/// <summary>
|
||||
/// Run the email watcher agent as a standalone process.
|
||||
/// </summary>
|
||||
public static async Task RunEmailWatcherAsync(string? configPath = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var config = LoadConfig(configPath);
|
||||
|
||||
Console.WriteLine("=== Email Watcher Agent ===");
|
||||
Console.WriteLine($"Gotify URL: {config.GotifyUrl ?? "(not configured)"}");
|
||||
Console.WriteLine($"Watching: {config.WatchFolder}");
|
||||
Console.WriteLine($"Notify on high importance: {config.NotifyOnHighImportance}");
|
||||
Console.WriteLine($"Notify on spam (>{config.SpamThreshold:P0}): {config.NotifyOnSpam}");
|
||||
Console.WriteLine($"Watched senders: {string.Join(", ", config.WatchedSenders)}");
|
||||
Console.WriteLine($"Watched keywords: {string.Join(", ", config.WatchedKeywords)}");
|
||||
Console.WriteLine();
|
||||
Console.WriteLine("Press Ctrl+C to stop...");
|
||||
Console.WriteLine();
|
||||
|
||||
using var agent = new EmailWatcherAgent(config);
|
||||
|
||||
// Start the agent
|
||||
await agent.StartAsync(cancellationToken);
|
||||
|
||||
// Wait until cancellation
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Expected on Ctrl+C
|
||||
}
|
||||
|
||||
await agent.StopAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a host with the email watcher agent for integration with other services.
|
||||
/// </summary>
|
||||
public static IHostBuilder CreateHostBuilder(string? configPath = null)
|
||||
{
|
||||
var config = LoadConfig(configPath);
|
||||
|
||||
return Host.CreateDefaultBuilder()
|
||||
.ConfigureServices(services =>
|
||||
{
|
||||
services.AddSingleton(config);
|
||||
services.AddHostedService<EmailWatcherAgent>();
|
||||
});
|
||||
}
|
||||
|
||||
private static EmailWatcherConfig LoadConfig(string? configPath)
|
||||
{
|
||||
// Try loading from file
|
||||
var path = configPath ?? Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "EmailWatcherConfig.json");
|
||||
|
||||
if (File.Exists(path))
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = File.ReadAllText(path);
|
||||
var config = JsonSerializer.Deserialize<EmailWatcherConfig>(json, new JsonSerializerOptions
|
||||
{
|
||||
PropertyNameCaseInsensitive = true
|
||||
});
|
||||
if (config != null)
|
||||
return config;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"Warning: Failed to load config from {path}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to environment variables
|
||||
return new EmailWatcherConfig
|
||||
{
|
||||
WatchFolder = Environment.GetEnvironmentVariable("EMAIL_WATCH_FOLDER") ?? "Inbox",
|
||||
GotifyUrl = Environment.GetEnvironmentVariable("GOTIFY_URL"),
|
||||
GotifyToken = Environment.GetEnvironmentVariable("GOTIFY_TOKEN"),
|
||||
WebhookUrl = Environment.GetEnvironmentVariable("WEBHOOK_URL"),
|
||||
NotifyOnHighImportance = true,
|
||||
NotifyOnSpam = true,
|
||||
SpamThreshold = 0.7,
|
||||
WatchedSenders = Environment.GetEnvironmentVariable("WATCH_SENDERS")?.Split(',').ToList() ?? new(),
|
||||
WatchedKeywords = Environment.GetEnvironmentVariable("WATCH_KEYWORDS")?.Split(',').ToList() ?? new()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,304 +0,0 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using System.Net.Http.Json;
|
||||
using NetOffice.OutlookApi;
|
||||
using NetOffice.OutlookApi.Enums;
|
||||
using EmailSearch.SpamDetection;
|
||||
using OutlookApp = NetOffice.OutlookApi.Application;
|
||||
using Exception = System.Exception;
|
||||
|
||||
namespace EmailSearch.Agents;
|
||||
|
||||
/// <summary>
|
||||
/// Background agent that watches for new emails and sends notifications
|
||||
/// when interesting emails arrive (e.g., high-priority, from specific senders, or spam detected).
|
||||
/// </summary>
|
||||
public class EmailWatcherAgent : BackgroundService
|
||||
{
|
||||
private readonly EmailWatcherConfig _config;
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly SpamDetector _spamDetector;
|
||||
private OutlookApp? _outlookApp;
|
||||
private _NameSpace? _namespace;
|
||||
private MAPIFolder? _watchFolder;
|
||||
private Items? _items;
|
||||
|
||||
public EmailWatcherAgent(EmailWatcherConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_httpClient = new HttpClient();
|
||||
_spamDetector = new SpamDetector();
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
Console.WriteLine($"[EmailWatcher] Starting... watching folder: {_config.WatchFolder}");
|
||||
|
||||
try
|
||||
{
|
||||
InitializeOutlook();
|
||||
Console.WriteLine("[EmailWatcher] Connected to Outlook. Waiting for new emails...");
|
||||
|
||||
// Keep service alive
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[EmailWatcher] Error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private void InitializeOutlook()
|
||||
{
|
||||
_outlookApp = new OutlookApp();
|
||||
_namespace = _outlookApp.GetNamespace("MAPI");
|
||||
|
||||
// Get the folder to watch
|
||||
_watchFolder = _config.WatchFolder.Equals("Inbox", StringComparison.OrdinalIgnoreCase)
|
||||
? _namespace.GetDefaultFolder(OlDefaultFolders.olFolderInbox)
|
||||
: FindFolder(_namespace, _config.WatchFolder);
|
||||
|
||||
if (_watchFolder == null)
|
||||
throw new Exception($"Folder '{_config.WatchFolder}' not found");
|
||||
|
||||
_items = (Items)_watchFolder.Items;
|
||||
|
||||
// Subscribe to new email events
|
||||
_items.ItemAddEvent += OnNewEmailReceived;
|
||||
|
||||
Console.WriteLine($"[EmailWatcher] Watching folder: {_watchFolder.Name}");
|
||||
}
|
||||
|
||||
private void OnNewEmailReceived(object item)
|
||||
{
|
||||
if (item is not MailItem mail)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"[EmailWatcher] New email: {mail.Subject}");
|
||||
|
||||
// Check if this email is "interesting" based on configured rules
|
||||
var notification = EvaluateEmail(mail);
|
||||
|
||||
if (notification != null)
|
||||
{
|
||||
// Fire and forget the notification
|
||||
_ = Task.Run(() => SendNotificationAsync(notification));
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[EmailWatcher] Error processing email: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private NotificationPayload? EvaluateEmail(MailItem mail)
|
||||
{
|
||||
var subject = mail.Subject ?? "(No Subject)";
|
||||
var sender = mail.SenderName ?? mail.SenderEmailAddress ?? "Unknown";
|
||||
var senderEmail = mail.SenderEmailAddress ?? "";
|
||||
|
||||
// Rule 1: High importance emails
|
||||
if (_config.NotifyOnHighImportance && mail.Importance == OlImportance.olImportanceHigh)
|
||||
{
|
||||
return new NotificationPayload
|
||||
{
|
||||
Title = "High Priority Email",
|
||||
Message = $"From: {sender}\n{subject}",
|
||||
Priority = 8
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 2: Specific senders
|
||||
if (_config.WatchedSenders.Any(s =>
|
||||
senderEmail.Contains(s, StringComparison.OrdinalIgnoreCase) ||
|
||||
sender.Contains(s, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return new NotificationPayload
|
||||
{
|
||||
Title = $"Email from {sender}",
|
||||
Message = subject,
|
||||
Priority = 7
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 3: Keyword matches in subject
|
||||
var matchedKeyword = _config.WatchedKeywords
|
||||
.FirstOrDefault(k => subject.Contains(k, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (matchedKeyword != null)
|
||||
{
|
||||
return new NotificationPayload
|
||||
{
|
||||
Title = $"Email matching '{matchedKeyword}'",
|
||||
Message = $"From: {sender}\n{subject}",
|
||||
Priority = 6
|
||||
};
|
||||
}
|
||||
|
||||
// Rule 4: Spam detection alert
|
||||
if (_config.NotifyOnSpam)
|
||||
{
|
||||
var spamResult = _spamDetector.Analyze(mail);
|
||||
if (spamResult.FinalScore >= _config.SpamThreshold)
|
||||
{
|
||||
return new NotificationPayload
|
||||
{
|
||||
Title = $"Spam Detected ({spamResult.FinalScore:P0})",
|
||||
Message = $"From: {sender}\n{subject}\nFlags: {string.Join(", ", spamResult.RedFlags.Take(3))}",
|
||||
Priority = 5
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Rule 5: Notify on all emails (if enabled)
|
||||
if (_config.NotifyOnAll)
|
||||
{
|
||||
return new NotificationPayload
|
||||
{
|
||||
Title = "New Email",
|
||||
Message = $"From: {sender}\n{subject}",
|
||||
Priority = 3
|
||||
};
|
||||
}
|
||||
|
||||
return null; // Not interesting
|
||||
}
|
||||
|
||||
private async Task SendNotificationAsync(NotificationPayload notification)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!string.IsNullOrEmpty(_config.GotifyUrl) && !string.IsNullOrEmpty(_config.GotifyToken))
|
||||
{
|
||||
await SendGotifyAsync(notification);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(_config.WebhookUrl))
|
||||
{
|
||||
await SendWebhookAsync(notification);
|
||||
}
|
||||
|
||||
// Console output as fallback
|
||||
Console.WriteLine($"[NOTIFY] {notification.Title}: {notification.Message}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"[EmailWatcher] Failed to send notification: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendGotifyAsync(NotificationPayload notification)
|
||||
{
|
||||
var url = $"{_config.GotifyUrl.TrimEnd('/')}/message?token={_config.GotifyToken}";
|
||||
|
||||
var payload = new
|
||||
{
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
priority = notification.Priority
|
||||
};
|
||||
|
||||
var response = await _httpClient.PostAsJsonAsync(url, payload);
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
Console.WriteLine($"[Gotify] Failed: {response.StatusCode}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SendWebhookAsync(NotificationPayload notification)
|
||||
{
|
||||
var payload = new
|
||||
{
|
||||
title = notification.Title,
|
||||
message = notification.Message,
|
||||
priority = notification.Priority,
|
||||
timestamp = DateTime.UtcNow
|
||||
};
|
||||
|
||||
await _httpClient.PostAsJsonAsync(_config.WebhookUrl!, payload);
|
||||
}
|
||||
|
||||
private static MAPIFolder? FindFolder(_NameSpace ns, string folderName)
|
||||
{
|
||||
foreach (var store in ns.Stores)
|
||||
{
|
||||
if (store is Store s)
|
||||
{
|
||||
try
|
||||
{
|
||||
var rootFolder = s.GetRootFolder() as MAPIFolder;
|
||||
if (rootFolder != null)
|
||||
{
|
||||
var found = SearchFolderRecursive(rootFolder, folderName);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static MAPIFolder? SearchFolderRecursive(MAPIFolder parent, string folderName)
|
||||
{
|
||||
foreach (var subfolder in parent.Folders)
|
||||
{
|
||||
if (subfolder is MAPIFolder folder)
|
||||
{
|
||||
if (folder.Name.Equals(folderName, StringComparison.OrdinalIgnoreCase))
|
||||
return folder;
|
||||
|
||||
var found = SearchFolderRecursive(folder, folderName);
|
||||
if (found != null)
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public override void Dispose()
|
||||
{
|
||||
Console.WriteLine("[EmailWatcher] Shutting down...");
|
||||
|
||||
_items?.Dispose();
|
||||
_watchFolder?.Dispose();
|
||||
_namespace?.Dispose();
|
||||
_outlookApp?.Dispose();
|
||||
_httpClient.Dispose();
|
||||
|
||||
base.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public class NotificationPayload
|
||||
{
|
||||
public string Title { get; set; } = "";
|
||||
public string Message { get; set; } = "";
|
||||
public int Priority { get; set; } = 5; // Gotify: 1-10
|
||||
}
|
||||
|
||||
public class EmailWatcherConfig
|
||||
{
|
||||
public string WatchFolder { get; set; } = "Inbox";
|
||||
|
||||
// Notification destinations
|
||||
public string? GotifyUrl { get; set; } // e.g., "http://gotify.local:8080"
|
||||
public string? GotifyToken { get; set; } // App token from Gotify
|
||||
public string? WebhookUrl { get; set; } // Generic webhook endpoint
|
||||
|
||||
// What to notify on
|
||||
public bool NotifyOnHighImportance { get; set; } = true;
|
||||
public bool NotifyOnSpam { get; set; } = true;
|
||||
public double SpamThreshold { get; set; } = 0.7;
|
||||
public bool NotifyOnAll { get; set; } = false;
|
||||
|
||||
// Watch lists
|
||||
public List<string> WatchedSenders { get; set; } = new(); // Email addresses or names
|
||||
public List<string> WatchedKeywords { get; set; } = new(); // Subject keywords
|
||||
}
|
||||
@@ -80,13 +80,16 @@ public class EmailSearchTools
|
||||
public static string MoveToJunk(
|
||||
[Description("Exact or partial subject line to match")] string subject,
|
||||
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date,
|
||||
[Description("Outlook folder to search for the email: Inbox, SentMail, Drafts, All, or any custom folder name (default Inbox)")] string folder = "Inbox")
|
||||
[Description("Outlook folder to search for the email: Inbox, SentMail, Drafts, All, or any custom folder name (default Inbox)")] string folder = "Inbox",
|
||||
[Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryParseDate(date, out var targetDate))
|
||||
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
|
||||
|
||||
TimeSpan? targetTime = TryParseTime(time);
|
||||
|
||||
using var outlookApp = new OutlookApp();
|
||||
var ns = outlookApp.GetNamespace("MAPI");
|
||||
var foldersToSearch = GetFoldersToSearch(ns, folder);
|
||||
@@ -94,7 +97,7 @@ public class EmailSearchTools
|
||||
|
||||
foreach (var mailFolder in foldersToSearch)
|
||||
{
|
||||
var mail = FindEmail(mailFolder, subject, targetDate);
|
||||
var mail = FindEmail(mailFolder, subject, targetDate, targetTime);
|
||||
if (mail != null)
|
||||
{
|
||||
var emailSubject = mail.Subject;
|
||||
@@ -115,20 +118,23 @@ public class EmailSearchTools
|
||||
public static string ReadEmail(
|
||||
[Description("Exact or partial subject line to match")] string subject,
|
||||
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date,
|
||||
[Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All")
|
||||
[Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All",
|
||||
[Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryParseDate(date, out var targetDate))
|
||||
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
|
||||
|
||||
TimeSpan? targetTime = TryParseTime(time);
|
||||
|
||||
using var outlookApp = new OutlookApp();
|
||||
var ns = outlookApp.GetNamespace("MAPI");
|
||||
var foldersToSearch = GetFoldersToSearch(ns, folder);
|
||||
|
||||
foreach (var mailFolder in foldersToSearch)
|
||||
{
|
||||
var mail = FindEmail(mailFolder, subject, targetDate);
|
||||
var mail = FindEmail(mailFolder, subject, targetDate, targetTime);
|
||||
if (mail != null)
|
||||
return FormatFullEmail(mail);
|
||||
}
|
||||
@@ -145,20 +151,23 @@ public class EmailSearchTools
|
||||
public static string AnalyzeSpam(
|
||||
[Description("Exact or partial subject line to match")] string subject,
|
||||
[Description("Date of the email (supports: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy)")] string date,
|
||||
[Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All")
|
||||
[Description("Outlook folder: Inbox, SentMail, Drafts, DeletedItems, Junk, All, or any custom folder name (default All)")] string folder = "All",
|
||||
[Description("Optional time to find the closest match when multiple emails share the same subject and date (e.g., '13:46', '1:46 PM')")] string? time = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!TryParseDate(date, out var targetDate))
|
||||
return $"Invalid date format '{date}'. Supported formats: yyyy-MM-dd, MM/dd/yyyy, dd/MM/yyyy";
|
||||
|
||||
TimeSpan? targetTime = TryParseTime(time);
|
||||
|
||||
using var outlookApp = new OutlookApp();
|
||||
var ns = outlookApp.GetNamespace("MAPI");
|
||||
var foldersToSearch = GetFoldersToSearch(ns, folder);
|
||||
|
||||
foreach (var mailFolder in foldersToSearch)
|
||||
{
|
||||
var mail = FindEmail(mailFolder, subject, targetDate);
|
||||
var mail = FindEmail(mailFolder, subject, targetDate, targetTime);
|
||||
if (mail != null)
|
||||
{
|
||||
var detector = new SpamDetector();
|
||||
@@ -436,7 +445,7 @@ public class EmailSearchTools
|
||||
}
|
||||
}
|
||||
|
||||
private static MailItem? FindEmail(MAPIFolder folder, string subject, DateTime targetDate)
|
||||
private static MailItem? FindEmail(MAPIFolder folder, string subject, DateTime targetDate, TimeSpan? targetTime = null)
|
||||
{
|
||||
var items = folder.Items;
|
||||
items.Sort("[ReceivedTime]", true);
|
||||
@@ -445,6 +454,31 @@ public class EmailSearchTools
|
||||
var filter = $"[ReceivedTime] >= '{targetDate:MM/dd/yyyy}' AND [ReceivedTime] < '{targetDate.AddDays(1):MM/dd/yyyy}'";
|
||||
var filteredItems = items.Restrict(filter);
|
||||
|
||||
if (targetTime.HasValue)
|
||||
{
|
||||
// Find the email closest to the specified time
|
||||
var targetDateTime = targetDate.Date + targetTime.Value;
|
||||
MailItem? bestMatch = null;
|
||||
var bestDiff = TimeSpan.MaxValue;
|
||||
|
||||
foreach (var item in filteredItems)
|
||||
{
|
||||
if (item is MailItem mail &&
|
||||
mail.Subject != null &&
|
||||
mail.Subject.Contains(subject, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var diff = (mail.ReceivedTime - targetDateTime).Duration();
|
||||
if (diff < bestDiff)
|
||||
{
|
||||
bestDiff = diff;
|
||||
bestMatch = mail;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return bestMatch;
|
||||
}
|
||||
|
||||
foreach (var item in filteredItems)
|
||||
{
|
||||
if (item is MailItem mail &&
|
||||
@@ -664,6 +698,17 @@ public class EmailSearchTools
|
||||
out result);
|
||||
}
|
||||
|
||||
private static TimeSpan? TryParseTime(string? timeString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(timeString))
|
||||
return null;
|
||||
|
||||
if (DateTime.TryParse(timeString, CultureInfo.InvariantCulture, DateTimeStyles.NoCurrentDateDefault, out var parsed))
|
||||
return parsed.TimeOfDay;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static OlImportance? ParseImportance(string? importance)
|
||||
{
|
||||
if (string.IsNullOrEmpty(importance)) return 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"
|
||||
]
|
||||
}
|
||||
@@ -1,30 +1,12 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using ModelContextProtocol.Server;
|
||||
using EmailSearch.Agents;
|
||||
|
||||
// Check for agent mode via command line args
|
||||
if (args.Length > 0 && args[0].Equals("--agent", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Run as AI Email Agent
|
||||
var cts = new CancellationTokenSource();
|
||||
Console.CancelKeyPress += (_, e) =>
|
||||
{
|
||||
e.Cancel = true;
|
||||
cts.Cancel();
|
||||
};
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithTools<EmailSearchTools>();
|
||||
|
||||
await AIAgentRunner.RunAsync(cts.Token);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Run as MCP Server (default)
|
||||
var builder = Host.CreateApplicationBuilder(args);
|
||||
builder.Services
|
||||
.AddMcpServer()
|
||||
.WithStdioServerTransport()
|
||||
.WithTools<EmailSearchTools>();
|
||||
|
||||
var app = builder.Build();
|
||||
await app.RunAsync();
|
||||
}
|
||||
var app = builder.Build();
|
||||
await app.RunAsync();
|
||||
|
||||
Reference in New Issue
Block a user