Compare commits
10 Commits
b45d6dffc1
...
66cca61b21
| Author | SHA1 | Date | |
|---|---|---|---|
| 66cca61b21 | |||
| cd9adc5a54 | |||
| 98e2da745a | |||
| f907f9e8f1 | |||
| a3a8f2e4be | |||
| 3401cfce34 | |||
| 807b3ebb7f | |||
| 7568d3d288 | |||
| bd42cc3382 | |||
| 78f5ca864d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -482,3 +482,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# User configuration with secrets
|
||||
src/SpamGuard/appsettings.json
|
||||
|
||||
24
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
24
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace SpamGuard.Configuration;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class SpamGuardOptionsValidator : IValidateOptions<SpamGuardOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SpamGuardOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
if (string.IsNullOrWhiteSpace(options.Imap.Host))
|
||||
errors.Add("SpamGuard:Imap:Host is required.");
|
||||
if (string.IsNullOrWhiteSpace(options.Imap.Username))
|
||||
errors.Add("SpamGuard:Imap:Username is required.");
|
||||
if (string.IsNullOrWhiteSpace(options.Imap.Password))
|
||||
errors.Add("SpamGuard:Imap:Password is required.");
|
||||
if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
|
||||
errors.Add("SpamGuard:Claude:ApiKey is required.");
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -1,2 +1,96 @@
|
||||
// See https://aka.ms/new-console-template for more information
|
||||
Console.WriteLine("Hello, World!");
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Services;
|
||||
using SpamGuard.State;
|
||||
using SpamGuard.Tray;
|
||||
|
||||
namespace SpamGuard;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||
|
||||
var dataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"SpamGuard");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.File(
|
||||
Path.Combine(dataDir, "logs", "spamguard-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
IHost? host = null;
|
||||
try
|
||||
{
|
||||
host = Host.CreateDefaultBuilder()
|
||||
.UseSerilog()
|
||||
.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddJsonFile(
|
||||
Path.Combine(AppContext.BaseDirectory, "appsettings.json"),
|
||||
optional: false);
|
||||
config.AddEnvironmentVariables("SPAMGUARD_");
|
||||
})
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.Configure<SpamGuardOptions>(
|
||||
context.Configuration.GetSection(SpamGuardOptions.SectionName));
|
||||
|
||||
// Validate required configuration on startup
|
||||
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
|
||||
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
|
||||
|
||||
// State stores
|
||||
services.AddSingleton(new ProcessedUidStore(dataDir));
|
||||
services.AddSingleton(new TrustedSenderStore(dataDir));
|
||||
|
||||
// Services
|
||||
services.AddSingleton(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
|
||||
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
|
||||
});
|
||||
services.AddSingleton<ImapClientFactory>();
|
||||
|
||||
// EmailClassifier with managed HttpClient (timeout + base address)
|
||||
services.AddHttpClient<EmailClassifier>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.BaseAddress = new Uri("https://api.anthropic.com/");
|
||||
});
|
||||
|
||||
// Background services
|
||||
services.AddSingleton<InboxMonitorService>();
|
||||
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>());
|
||||
services.AddHostedService<TrustedSenderService>();
|
||||
})
|
||||
.Build();
|
||||
|
||||
host.Start();
|
||||
|
||||
Application.Run(new TrayApplicationContext(host));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application failed to start");
|
||||
}
|
||||
finally
|
||||
{
|
||||
host?.StopAsync().GetAwaiter().GetResult();
|
||||
host?.Dispose();
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
130
src/SpamGuard/Services/EmailClassifier.cs
Normal file
130
src/SpamGuard/Services/EmailClassifier.cs
Normal file
@@ -0,0 +1,130 @@
|
||||
// src/SpamGuard/Services/EmailClassifier.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
|
||||
public sealed partial class EmailClassifier
|
||||
{
|
||||
private readonly SpamGuardOptions _options;
|
||||
private readonly ILogger<EmailClassifier> _logger;
|
||||
private readonly HttpClient _httpClient;
|
||||
|
||||
private const string SystemPrompt = """
|
||||
You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate.
|
||||
|
||||
Spam includes:
|
||||
- Unsolicited marketing or promotional emails the recipient never signed up for
|
||||
- AI-generated emails designed to look like legitimate correspondence
|
||||
- Newsletter signups the recipient didn't request
|
||||
|
||||
Legitimate includes:
|
||||
- Emails from known contacts or businesses the recipient has a relationship with
|
||||
- Transactional emails (receipts, shipping notifications, password resets)
|
||||
- Emails the recipient would expect to receive
|
||||
|
||||
Respond with JSON only:
|
||||
{"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"}
|
||||
""";
|
||||
|
||||
public EmailClassifier(
|
||||
IOptions<SpamGuardOptions> options,
|
||||
ILogger<EmailClassifier> logger,
|
||||
HttpClient httpClient)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
}
|
||||
|
||||
public string BuildPrompt(EmailSummary email)
|
||||
{
|
||||
var body = email.BodySnippet.Length > _options.Claude.MaxBodyLength
|
||||
? email.BodySnippet[.._options.Claude.MaxBodyLength]
|
||||
: email.BodySnippet;
|
||||
|
||||
return $"""
|
||||
Email details:
|
||||
From: {email.From}
|
||||
Subject: {email.Subject}
|
||||
Body: {body}
|
||||
""";
|
||||
}
|
||||
|
||||
public async Task<ClassificationResult?> ClassifyAsync(EmailSummary email, CancellationToken ct = default)
|
||||
{
|
||||
var userMessage = BuildPrompt(email);
|
||||
|
||||
_logger.LogDebug("Classifying email UID={Uid} from {From}", email.Uid, email.From);
|
||||
|
||||
var requestBody = new
|
||||
{
|
||||
model = _options.Claude.Model,
|
||||
max_tokens = 256,
|
||||
system = SystemPrompt,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "user", content = userMessage }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
|
||||
{
|
||||
Content = new StringContent(json, Encoding.UTF8, "application/json")
|
||||
};
|
||||
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
|
||||
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||
|
||||
var response = await _httpClient.SendAsync(request, ct);
|
||||
response.EnsureSuccessStatusCode();
|
||||
|
||||
var responseJson = await response.Content.ReadAsStringAsync(ct);
|
||||
using var doc = JsonDocument.Parse(responseJson);
|
||||
var text = doc.RootElement
|
||||
.GetProperty("content")[0]
|
||||
.GetProperty("text")
|
||||
.GetString() ?? "";
|
||||
|
||||
var result = ParseResponse(text);
|
||||
|
||||
if (result != null)
|
||||
_logger.LogInformation(
|
||||
"UID={Uid} classified as {Classification} (confidence={Confidence}): {Reason}",
|
||||
email.Uid, result.Classification, result.Confidence, result.Reason);
|
||||
else
|
||||
_logger.LogWarning("UID={Uid} classification failed to parse: {Text}", email.Uid, text);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ClassificationResult? ParseResponse(string text)
|
||||
{
|
||||
// Strip markdown code fencing if present
|
||||
var cleaned = StripMarkdownFencing().Replace(text, "$1").Trim();
|
||||
|
||||
try
|
||||
{
|
||||
using var doc = JsonDocument.Parse(cleaned);
|
||||
var root = doc.RootElement;
|
||||
|
||||
return new ClassificationResult(
|
||||
Classification: root.GetProperty("classification").GetString() ?? "unknown",
|
||||
Confidence: root.GetProperty("confidence").GetDouble(),
|
||||
Reason: root.GetProperty("reason").GetString() ?? ""
|
||||
);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.Compiled)]
|
||||
private static partial Regex StripMarkdownFencing();
|
||||
}
|
||||
34
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
34
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
@@ -0,0 +1,34 @@
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpamGuard.Configuration;
|
||||
|
||||
public sealed class ImapClientFactory
|
||||
{
|
||||
private readonly SpamGuardOptions _options;
|
||||
private readonly ILogger<ImapClientFactory> _logger;
|
||||
|
||||
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ImapClient> CreateConnectedClientAsync(CancellationToken ct = default)
|
||||
{
|
||||
var client = new ImapClient();
|
||||
var imap = _options.Imap;
|
||||
|
||||
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
|
||||
|
||||
var secureSocketOptions = imap.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable;
|
||||
await client.ConnectAsync(imap.Host, imap.Port, secureSocketOptions, ct);
|
||||
await client.AuthenticateAsync(imap.Username, imap.Password, ct);
|
||||
|
||||
_logger.LogDebug("Connected and authenticated as {Username}", imap.Username);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
256
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
256
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
@@ -0,0 +1,256 @@
|
||||
// src/SpamGuard/Services/InboxMonitorService.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit;
|
||||
using MailKit.Search;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.State;
|
||||
|
||||
public sealed partial class InboxMonitorService : BackgroundService
|
||||
{
|
||||
private readonly ImapClientFactory _imapFactory;
|
||||
private readonly TrustedSenderStore _trustedSenders;
|
||||
private readonly ProcessedUidStore _processedUids;
|
||||
private readonly EmailClassifier _classifier;
|
||||
private readonly ActivityLog _activityLog;
|
||||
private readonly SpamGuardOptions _options;
|
||||
private readonly ILogger<InboxMonitorService> _logger;
|
||||
|
||||
private volatile bool _paused;
|
||||
private uint _lastSeenUid;
|
||||
|
||||
public bool IsPaused => _paused;
|
||||
public void Pause() => _paused = true;
|
||||
public void Resume() => _paused = false;
|
||||
|
||||
public InboxMonitorService(
|
||||
ImapClientFactory imapFactory,
|
||||
TrustedSenderStore trustedSenders,
|
||||
ProcessedUidStore processedUids,
|
||||
EmailClassifier classifier,
|
||||
ActivityLog activityLog,
|
||||
IOptions<SpamGuardOptions> options,
|
||||
ILogger<InboxMonitorService> logger)
|
||||
{
|
||||
_imapFactory = imapFactory;
|
||||
_trustedSenders = trustedSenders;
|
||||
_processedUids = processedUids;
|
||||
_classifier = classifier;
|
||||
_activityLog = activityLog;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("InboxMonitorService started");
|
||||
|
||||
// Brief delay to let TrustedSenderService do its first scan
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_paused)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PollInboxAsync(stoppingToken);
|
||||
_processedUids.Prune(TimeSpan.FromDays(30));
|
||||
_processedUids.Save();
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error polling inbox");
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message));
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(_options.Monitoring.PollIntervalSeconds), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollInboxAsync(CancellationToken ct)
|
||||
{
|
||||
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
|
||||
var inbox = client.Inbox;
|
||||
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
|
||||
|
||||
// Build search query: only fetch new messages
|
||||
IList<UniqueId> uids;
|
||||
if (_lastSeenUid > 0)
|
||||
{
|
||||
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
|
||||
uids = await inbox.SearchAsync(range, SearchQuery.All, ct);
|
||||
}
|
||||
else if (_processedUids.Count > 0)
|
||||
{
|
||||
// Resuming from persisted state -- scan recent messages only
|
||||
uids = await inbox.SearchAsync(
|
||||
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-1)), ct);
|
||||
}
|
||||
else
|
||||
{
|
||||
// First ever run -- initial scan window
|
||||
uids = await inbox.SearchAsync(
|
||||
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct);
|
||||
}
|
||||
_logger.LogDebug("Found {Count} messages in inbox", uids.Count);
|
||||
|
||||
// Find the spam/junk folder
|
||||
var spamFolder = await FindSpamFolderAsync(client, ct);
|
||||
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
if (_processedUids.Contains(uid.Id))
|
||||
{
|
||||
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await ProcessMessageAsync(inbox, uid, spamFolder, ct);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message));
|
||||
_processedUids.Add(uid.Id);
|
||||
}
|
||||
|
||||
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
|
||||
}
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(
|
||||
IMailFolder inbox, UniqueId uid, IMailFolder? spamFolder, CancellationToken ct)
|
||||
{
|
||||
var message = await inbox.GetMessageAsync(uid, ct);
|
||||
var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown";
|
||||
var subject = message.Subject ?? "(no subject)";
|
||||
|
||||
// Check trusted senders
|
||||
if (_trustedSenders.IsTrusted(from))
|
||||
{
|
||||
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null));
|
||||
_processedUids.Add(uid.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract body snippet
|
||||
var bodySnippet = ExtractBodySnippet(message);
|
||||
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
|
||||
|
||||
// Classify
|
||||
var result = await _classifier.ClassifyAsync(emailSummary, ct);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed"));
|
||||
_processedUids.Add(uid.Id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold)
|
||||
{
|
||||
// Move to spam folder
|
||||
if (spamFolder != null)
|
||||
{
|
||||
await inbox.MoveToAsync(uid, spamFolder, ct);
|
||||
_logger.LogInformation("Moved UID={Uid} to spam: {Reason}", uid.Id, result.Reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Spam detected but no spam folder found, flagging instead");
|
||||
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
|
||||
}
|
||||
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason));
|
||||
}
|
||||
else if (result.IsSpam)
|
||||
{
|
||||
// Below threshold -- uncertain
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason));
|
||||
}
|
||||
else
|
||||
{
|
||||
_activityLog.Add(new ActivityEntry(
|
||||
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason));
|
||||
}
|
||||
|
||||
_processedUids.Add(uid.Id);
|
||||
}
|
||||
|
||||
private static string ExtractBodySnippet(MimeMessage message)
|
||||
{
|
||||
var text = message.TextBody;
|
||||
|
||||
if (text == null && message.HtmlBody != null)
|
||||
{
|
||||
// Strip script and style blocks first, then remaining tags
|
||||
text = StripScriptStyle().Replace(message.HtmlBody, " ");
|
||||
text = StripHtmlTags().Replace(text, " ");
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
text = CollapseWhitespace().Replace(text, " ").Trim();
|
||||
}
|
||||
|
||||
text ??= "";
|
||||
return text.Length > 2000 ? text[..2000] : text;
|
||||
}
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"<(script|style)[^>]*>[\s\S]*?</\1>", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
|
||||
private static partial System.Text.RegularExpressions.Regex StripScriptStyle();
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial System.Text.RegularExpressions.Regex StripHtmlTags();
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")]
|
||||
private static partial System.Text.RegularExpressions.Regex CollapseWhitespace();
|
||||
|
||||
private async Task<IMailFolder?> FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
|
||||
{
|
||||
// Try special folder first
|
||||
try
|
||||
{
|
||||
var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
|
||||
if (junk != null) return junk;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not get Junk special folder");
|
||||
}
|
||||
|
||||
// Fall back to configured folder name
|
||||
try
|
||||
{
|
||||
var personal = client.GetFolder(client.PersonalNamespaces[0]);
|
||||
var folders = await personal.GetSubfoldersAsync(false, ct);
|
||||
return folders.FirstOrDefault(f =>
|
||||
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
104
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
104
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
@@ -0,0 +1,104 @@
|
||||
// src/SpamGuard/Services/TrustedSenderService.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit;
|
||||
using MailKit.Search;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MimeKit;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.State;
|
||||
|
||||
public sealed class TrustedSenderService : BackgroundService
|
||||
{
|
||||
private readonly ImapClientFactory _imapFactory;
|
||||
private readonly TrustedSenderStore _store;
|
||||
private readonly SpamGuardOptions _options;
|
||||
private readonly ILogger<TrustedSenderService> _logger;
|
||||
private bool _initialScanDone;
|
||||
|
||||
public TrustedSenderService(
|
||||
ImapClientFactory imapFactory,
|
||||
TrustedSenderStore store,
|
||||
IOptions<SpamGuardOptions> options,
|
||||
ILogger<TrustedSenderService> logger)
|
||||
{
|
||||
_imapFactory = imapFactory;
|
||||
_store = store;
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("TrustedSenderService started");
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanSentFolderAsync(stoppingToken);
|
||||
_store.Save();
|
||||
_logger.LogInformation("Trusted sender scan complete. {Count} trusted senders", _store.Count);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error scanning sent folder");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(_options.Monitoring.TrustedSenderRefreshMinutes), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanSentFolderAsync(CancellationToken ct)
|
||||
{
|
||||
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
|
||||
|
||||
var sentFolder = client.GetFolder(MailKit.SpecialFolder.Sent)
|
||||
?? throw new InvalidOperationException("Could not find Sent folder");
|
||||
|
||||
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||
|
||||
// After initial full scan, only check messages from the last refresh period
|
||||
var query = _initialScanDone
|
||||
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_options.Monitoring.TrustedSenderRefreshMinutes))
|
||||
: SearchQuery.All;
|
||||
|
||||
var uids = await sentFolder.SearchAsync(query, ct);
|
||||
_logger.LogDebug("Found {Count} messages in Sent folder to scan", uids.Count);
|
||||
|
||||
var addresses = new List<string>();
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
var headers = await sentFolder.GetHeadersAsync(uid, ct);
|
||||
ExtractAddresses(headers, addresses);
|
||||
}
|
||||
|
||||
_store.AddRange(addresses);
|
||||
_initialScanDone = true;
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
private static void ExtractAddresses(HeaderList headers, List<string> addresses)
|
||||
{
|
||||
foreach (var headerName in new[] { "To", "Cc" })
|
||||
{
|
||||
var value = headers[headerName];
|
||||
if (string.IsNullOrEmpty(value)) continue;
|
||||
|
||||
if (InternetAddressList.TryParse(value, out var list))
|
||||
{
|
||||
foreach (var address in list.Mailboxes)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(address.Address))
|
||||
addresses.Add(address.Address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,9 @@
|
||||
<PackageReference Include="MailKit" Version="4.*" />
|
||||
<PackageReference Include="Anthropic" Version="12.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.*" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.*" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.*" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
||||
|
||||
@@ -52,7 +52,9 @@ public sealed class ProcessedUidStore
|
||||
lock (_lock)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_uids);
|
||||
File.WriteAllText(_filePath, json);
|
||||
var tempPath = _filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ public sealed class TrustedSenderStore
|
||||
lock (_lock)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_senders);
|
||||
File.WriteAllText(_filePath, json);
|
||||
var tempPath = _filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
95
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
95
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
@@ -0,0 +1,95 @@
|
||||
namespace SpamGuard.Tray;
|
||||
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.Services;
|
||||
|
||||
public sealed class ActivityLogForm : Form
|
||||
{
|
||||
private readonly ActivityLog _activityLog;
|
||||
private readonly DataGridView _grid;
|
||||
|
||||
public ActivityLogForm(ActivityLog activityLog)
|
||||
{
|
||||
_activityLog = activityLog;
|
||||
|
||||
Text = "SpamGuard - Activity Log";
|
||||
Size = new System.Drawing.Size(800, 500);
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
Icon = IconGenerator.Green;
|
||||
|
||||
_grid = new DataGridView
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
BackgroundColor = System.Drawing.SystemColors.Window,
|
||||
BorderStyle = BorderStyle.None
|
||||
};
|
||||
|
||||
_grid.Columns.Add("Time", "Time");
|
||||
_grid.Columns.Add("From", "From");
|
||||
_grid.Columns.Add("Subject", "Subject");
|
||||
_grid.Columns.Add("Verdict", "Verdict");
|
||||
_grid.Columns.Add("Confidence", "Confidence");
|
||||
_grid.Columns.Add("Reason", "Reason");
|
||||
|
||||
_grid.Columns["Time"]!.Width = 70;
|
||||
_grid.Columns["From"]!.Width = 150;
|
||||
_grid.Columns["Verdict"]!.Width = 80;
|
||||
_grid.Columns["Confidence"]!.Width = 80;
|
||||
|
||||
Controls.Add(_grid);
|
||||
|
||||
RefreshData();
|
||||
}
|
||||
|
||||
public void RefreshData()
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
Invoke(RefreshData);
|
||||
return;
|
||||
}
|
||||
|
||||
var entries = _activityLog.GetRecent(200);
|
||||
_grid.Rows.Clear();
|
||||
|
||||
foreach (var entry in entries)
|
||||
{
|
||||
var rowIndex = _grid.Rows.Add(
|
||||
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
|
||||
entry.Sender,
|
||||
entry.Subject,
|
||||
entry.Verdict.ToString(),
|
||||
entry.Confidence?.ToString("P0") ?? "--",
|
||||
entry.Reason ?? ""
|
||||
);
|
||||
|
||||
var row = _grid.Rows[rowIndex];
|
||||
row.DefaultCellStyle.ForeColor = entry.Verdict switch
|
||||
{
|
||||
Verdict.Spam => System.Drawing.Color.Red,
|
||||
Verdict.Trusted => System.Drawing.Color.Green,
|
||||
Verdict.Uncertain => System.Drawing.Color.Orange,
|
||||
Verdict.Error => System.Drawing.Color.Gray,
|
||||
_ => System.Drawing.SystemColors.ControlText
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
39
src/SpamGuard/Tray/IconGenerator.cs
Normal file
39
src/SpamGuard/Tray/IconGenerator.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace SpamGuard.Tray;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class IconGenerator
|
||||
{
|
||||
private static readonly Lazy<Icon> _green = new(() => CreateCircleIcon(Color.FromArgb(76, 175, 80)));
|
||||
private static readonly Lazy<Icon> _yellow = new(() => CreateCircleIcon(Color.FromArgb(255, 193, 7)));
|
||||
private static readonly Lazy<Icon> _red = new(() => CreateCircleIcon(Color.FromArgb(244, 67, 54)));
|
||||
|
||||
public static Icon Green => _green.Value;
|
||||
public static Icon Yellow => _yellow.Value;
|
||||
public static Icon Red => _red.Value;
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
private static Icon CreateCircleIcon(Color color, int size = 16)
|
||||
{
|
||||
using var bitmap = new Bitmap(size, size);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.Clear(Color.Transparent);
|
||||
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, 1, 1, size - 2, size - 2);
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(100, 0, 0, 0), 1);
|
||||
graphics.DrawEllipse(pen, 1, 1, size - 3, size - 3);
|
||||
|
||||
var hIcon = bitmap.GetHicon();
|
||||
var icon = (Icon)Icon.FromHandle(hIcon).Clone();
|
||||
DestroyIcon(hIcon);
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
111
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
111
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
@@ -0,0 +1,111 @@
|
||||
// src/SpamGuard/Tray/TrayApplicationContext.cs
|
||||
namespace SpamGuard.Tray;
|
||||
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using SpamGuard.Services;
|
||||
|
||||
public sealed class TrayApplicationContext : ApplicationContext
|
||||
{
|
||||
private readonly NotifyIcon _notifyIcon;
|
||||
private readonly System.Windows.Forms.Timer _refreshTimer;
|
||||
private readonly ActivityLog _activityLog;
|
||||
private readonly InboxMonitorService _monitor;
|
||||
private readonly IHost _host;
|
||||
private ActivityLogForm? _logForm;
|
||||
private readonly ToolStripMenuItem _pauseMenuItem;
|
||||
|
||||
public TrayApplicationContext(IHost host)
|
||||
{
|
||||
_host = host;
|
||||
_activityLog = host.Services.GetRequiredService<ActivityLog>();
|
||||
_monitor = host.Services.GetRequiredService<InboxMonitorService>();
|
||||
|
||||
_pauseMenuItem = new ToolStripMenuItem("Pause", null, OnPauseResume);
|
||||
|
||||
var contextMenu = new ContextMenuStrip();
|
||||
contextMenu.Items.Add("View Activity Log", null, OnViewLog);
|
||||
contextMenu.Items.Add(_pauseMenuItem);
|
||||
contextMenu.Items.Add(new ToolStripSeparator());
|
||||
contextMenu.Items.Add("Quit", null, OnQuit);
|
||||
|
||||
_notifyIcon = new NotifyIcon
|
||||
{
|
||||
Icon = IconGenerator.Green,
|
||||
Text = "SpamGuard - Starting...",
|
||||
Visible = true,
|
||||
ContextMenuStrip = contextMenu
|
||||
};
|
||||
|
||||
_notifyIcon.DoubleClick += OnViewLog;
|
||||
|
||||
_refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 };
|
||||
_refreshTimer.Tick += OnRefreshTick;
|
||||
_refreshTimer.Start();
|
||||
}
|
||||
|
||||
private void OnRefreshTick(object? sender, EventArgs e)
|
||||
{
|
||||
var checked_ = _activityLog.TodayChecked;
|
||||
var spam = _activityLog.TodaySpam;
|
||||
var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
|
||||
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
|
||||
|
||||
// Update icon based on state
|
||||
if (!_monitor.IsPaused)
|
||||
{
|
||||
var recent = _activityLog.GetRecent(1);
|
||||
var hasRecentError = recent.Count > 0
|
||||
&& recent[0].Verdict == Models.Verdict.Error
|
||||
&& recent[0].Timestamp > DateTime.UtcNow.AddMinutes(-5);
|
||||
_notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green;
|
||||
}
|
||||
|
||||
_logForm?.RefreshData();
|
||||
}
|
||||
|
||||
private void OnViewLog(object? sender, EventArgs e)
|
||||
{
|
||||
if (_logForm == null || _logForm.IsDisposed)
|
||||
{
|
||||
_logForm = new ActivityLogForm(_activityLog);
|
||||
}
|
||||
_logForm.Show();
|
||||
_logForm.BringToFront();
|
||||
}
|
||||
|
||||
private void OnPauseResume(object? sender, EventArgs e)
|
||||
{
|
||||
if (_monitor.IsPaused)
|
||||
{
|
||||
_monitor.Resume();
|
||||
_pauseMenuItem.Text = "Pause";
|
||||
_notifyIcon.Icon = IconGenerator.Green;
|
||||
}
|
||||
else
|
||||
{
|
||||
_monitor.Pause();
|
||||
_pauseMenuItem.Text = "Resume";
|
||||
_notifyIcon.Icon = IconGenerator.Yellow;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnQuit(object? sender, EventArgs e)
|
||||
{
|
||||
_notifyIcon.Visible = false;
|
||||
_refreshTimer.Stop();
|
||||
await _host.StopAsync();
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_refreshTimer.Dispose();
|
||||
_notifyIcon.Dispose();
|
||||
_logForm?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
38
src/SpamGuard/appsettings.template.json
Normal file
38
src/SpamGuard/appsettings.template.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"SpamGuard": {
|
||||
"Imap": {
|
||||
"Host": "imap.example.com",
|
||||
"Port": 993,
|
||||
"UseSsl": true,
|
||||
"Username": "user@example.com",
|
||||
"Password": ""
|
||||
},
|
||||
"Claude": {
|
||||
"ApiKey": "",
|
||||
"Model": "claude-sonnet-4-6",
|
||||
"MaxBodyLength": 2000
|
||||
},
|
||||
"Monitoring": {
|
||||
"PollIntervalSeconds": 60,
|
||||
"TrustedSenderRefreshMinutes": 60,
|
||||
"SpamConfidenceThreshold": 0.7,
|
||||
"SpamFolderName": "Junk",
|
||||
"InitialScanDays": 7,
|
||||
"MaxActivityLogEntries": 500
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "%APPDATA%/SpamGuard/logs/spamguard-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 7
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
100
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
100
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
// tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
|
||||
namespace SpamGuard.Tests.Services;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.Services;
|
||||
|
||||
public class EmailClassifierTests
|
||||
{
|
||||
private static SpamGuardOptions DefaultOptions => new()
|
||||
{
|
||||
Claude = new ClaudeOptions
|
||||
{
|
||||
ApiKey = "test-key",
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxBodyLength = 2000
|
||||
}
|
||||
};
|
||||
|
||||
private static EmailSummary SampleEmail => new(
|
||||
Uid: 1,
|
||||
From: "spammer@sketchy.com",
|
||||
Subject: "Buy now! Limited offer!",
|
||||
BodySnippet: "Click here to claim your prize...",
|
||||
Date: DateTimeOffset.UtcNow
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_ContainsSenderAndSubjectAndBody()
|
||||
{
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient()
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(SampleEmail);
|
||||
|
||||
Assert.Contains("spammer@sketchy.com", prompt);
|
||||
Assert.Contains("Buy now! Limited offer!", prompt);
|
||||
Assert.Contains("Click here to claim your prize...", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_TruncatesLongBody()
|
||||
{
|
||||
var longBody = new string('x', 5000);
|
||||
var email = SampleEmail with { BodySnippet = longBody };
|
||||
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient()
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(email);
|
||||
|
||||
// Body in prompt should be truncated to MaxBodyLength
|
||||
Assert.DoesNotContain(longBody, prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_ValidJson_ReturnsResult()
|
||||
{
|
||||
var json = """{"classification": "spam", "confidence": 0.95, "reason": "Unsolicited marketing"}""";
|
||||
|
||||
var result = EmailClassifier.ParseResponse(json);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsSpam);
|
||||
Assert.Equal(0.95, result.Confidence);
|
||||
Assert.Equal("Unsolicited marketing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_InvalidJson_ReturnsNull()
|
||||
{
|
||||
var result = EmailClassifier.ParseResponse("not json at all");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_JsonWithMarkdownFencing_ReturnsResult()
|
||||
{
|
||||
var json = """
|
||||
```json
|
||||
{"classification": "legitimate", "confidence": 0.85, "reason": "Normal business email"}
|
||||
```
|
||||
""";
|
||||
|
||||
var result = EmailClassifier.ParseResponse(json);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsSpam);
|
||||
}
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace SpamGuard.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user