Compare commits

..

17 Commits

Author SHA1 Message Date
1c00512661 Improve IMAP reconnection and error handling in polling
Refactored PollInboxAsync to better handle IMAP connection drops and protocol errors by reconnecting and resuming processing as needed. Switched from using a using statement to explicit disposal in a finally block. Now logs and recovers from transient IMAP issues, and ensures proper cancellation handling by rethrowing OperationCanceledException. This increases service robustness and reliability.
2026-04-12 14:23:16 +01:00
387c0dc155 Commit pre-existing working changes (multi-account, blocked domains, overrides) 2026-04-12 10:10:58 +01:00
8aa35469fc Fix Verdict:Error from prose-wrapped AI responses 2026-04-12 10:08:57 +01:00
5c801cef4b Fix spam folder not found: add Junk Email to candidate names 2026-04-12 09:59:58 +01:00
b5f8b7300b Fix spam folder detection — search two levels deep, try common names 2026-04-12 09:50:24 +01:00
27c6d12183 Stop grid jumping: event-driven refresh instead of 5-second timer 2026-04-12 09:19:47 +01:00
4d8342b658 Fix activity log refresh losing scroll position and selection 2026-04-12 09:13:43 +01:00
66cca61b21 fix: address code review findings (critical + important + minor)
Critical fixes:
- C1: Fix GDI handle leak in IconGenerator (lazy singletons + DestroyIcon)
- C2: Add appsettings.json to .gitignore, provide template
- C3: Properly dispose IHost on exit

Important fixes:
- I1: Add SpamGuardOptionsValidator for startup config validation
- I3: Use UID range queries instead of SearchQuery.All
- I4: Only scan recent Sent messages after initial full scan
- I5: Atomic file writes (write-to-temp, then rename)
- I6: Set 30s HTTP timeout and base address on HttpClient
- I7: Remove duplicate AddSingleton<EmailClassifier> (DI conflict)
- I8: Better HTML stripping (script/style removal, entity decoding)

Minor fixes:
- M1: Delete placeholder UnitTest1.cs
- M2: Wire MaxActivityLogEntries config to ActivityLog constructor
- M4: Log warnings instead of bare catch blocks
- M5: Dispose JsonDocument instances
- M8: Use AppContext.BaseDirectory for appsettings.json path
- M9: Truncate NotifyIcon.Text to 127 chars

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:57:12 +01:00
cd9adc5a54 chore: remove placeholder icons (generated at runtime) 2026-04-07 11:47:56 +01:00
98e2da745a feat: wire up Program.cs with DI, background services, and tray UI
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:47:01 +01:00
f907f9e8f1 feat: add system tray UI with status icon, tooltip, and context menu 2026-04-07 11:45:20 +01:00
a3a8f2e4be feat: add ActivityLogForm with color-coded DataGridView 2026-04-07 11:45:10 +01:00
3401cfce34 feat: add InboxMonitorService with classification pipeline and spam moving 2026-04-07 11:44:14 +01:00
807b3ebb7f feat: add programmatic tray icon generation (green/yellow/red) 2026-04-07 11:42:49 +01:00
7568d3d288 feat: add TrustedSenderService to scan Sent folder for trusted contacts 2026-04-07 11:42:40 +01:00
bd42cc3382 feat: add EmailClassifier with Claude API integration and response parsing
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-07 11:41:45 +01:00
78f5ca864d feat: add ImapClientFactory for IMAP connection management 2026-04-07 11:40:27 +01:00
26 changed files with 1875 additions and 38 deletions

3
.gitignore vendored
View File

@@ -482,3 +482,6 @@ $RECYCLE.BIN/
# Vim temporary swap files # Vim temporary swap files
*.swp *.swp
# User configuration with secrets
src/SpamGuard/appsettings.json

View File

@@ -4,13 +4,14 @@ public sealed class SpamGuardOptions
{ {
public const string SectionName = "SpamGuard"; public const string SectionName = "SpamGuard";
public ImapOptions Imap { get; set; } = new(); public List<ImapOptions> Accounts { get; set; } = new();
public ClaudeOptions Claude { get; set; } = new(); public ClaudeOptions Claude { get; set; } = new();
public MonitoringOptions Monitoring { get; set; } = new(); public MonitoringOptions Monitoring { get; set; } = new();
} }
public sealed class ImapOptions public sealed class ImapOptions
{ {
public string Name { get; set; } = "";
public string Host { get; set; } = ""; public string Host { get; set; } = "";
public int Port { get; set; } = 993; public int Port { get; set; } = 993;
public bool UseSsl { get; set; } = true; public bool UseSsl { get; set; } = true;
@@ -21,6 +22,7 @@ public sealed class ImapOptions
public sealed class ClaudeOptions public sealed class ClaudeOptions
{ {
public string ApiKey { get; set; } = ""; public string ApiKey { get; set; } = "";
public string BaseUrl { get; set; } = "https://api.anthropic.com/";
public string Model { get; set; } = "claude-sonnet-4-6"; public string Model { get; set; } = "claude-sonnet-4-6";
public int MaxBodyLength { get; set; } = 2000; public int MaxBodyLength { get; set; } = 2000;
} }

View File

@@ -0,0 +1,33 @@
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 (options.Accounts.Count == 0)
errors.Add("SpamGuard:Accounts must contain at least one account.");
for (int i = 0; i < options.Accounts.Count; i++)
{
var a = options.Accounts[i];
var prefix = $"SpamGuard:Accounts[{i}]";
if (string.IsNullOrWhiteSpace(a.Host))
errors.Add($"{prefix}:Host is required.");
if (string.IsNullOrWhiteSpace(a.Username))
errors.Add($"{prefix}:Username is required.");
if (string.IsNullOrWhiteSpace(a.Password))
errors.Add($"{prefix}: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;
}
}

View File

@@ -10,11 +10,22 @@ public enum Verdict
Error Error
} }
public sealed record ActivityEntry( public enum UserOverride
DateTime Timestamp, {
string Sender, MarkedSpam,
string Subject, MarkedLegitimate
Verdict Verdict, }
double? Confidence,
string? Reason public sealed class ActivityEntry
); {
public DateTime Timestamp { get; init; }
public string Sender { get; init; } = "";
public string Subject { get; init; } = "";
public Verdict Verdict { get; set; }
public double? Confidence { get; init; }
public string? Reason { get; init; }
public uint Uid { get; init; }
public string AccountName { get; init; } = "";
public string? BodySnippet { get; init; }
public UserOverride? Override { get; set; }
}

View File

@@ -1,2 +1,150 @@
// See https://aka.ms/new-console-template for more information using Microsoft.Extensions.Configuration;
Console.WriteLine("Hello, World!"); 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;
#pragma warning disable CA1416 // Validate platform compatibility
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>();
var opts = context.Configuration
.GetSection(SpamGuardOptions.SectionName)
.Get<SpamGuardOptions>()!;
// Shared state / services
services.AddSingleton(new TrustedSenderStore(dataDir));
services.AddSingleton(new BlockedDomainStore(dataDir));
services.AddSingleton(new OverrideStore(dataDir));
services.AddSingleton(new ActivityLog(opts.Monitoring.MaxActivityLogEntries, dataDir));
services.AddSingleton<ImapClientFactory>();
// EmailClassifier with managed HttpClient
services.AddHttpClient<EmailClassifier>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.BaseAddress = new Uri(opts.Claude.BaseUrl);
});
// Per-account monitor + trusted sender service
var monitors = new List<InboxMonitorService>();
foreach (var account in opts.Accounts)
{
var accountName = account.Name.Length > 0 ? account.Name : account.Username;
var uidStore = new ProcessedUidStore(dataDir, accountName);
services.AddHostedService(sp => new TrustedSenderService(
sp.GetRequiredService<ImapClientFactory>(),
sp.GetRequiredService<TrustedSenderStore>(),
account,
opts.Monitoring,
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<TrustedSenderService>>()));
var capturedAccount = account;
services.AddSingleton(sp =>
{
var monitor = new InboxMonitorService(
sp.GetRequiredService<ImapClientFactory>(),
sp.GetRequiredService<TrustedSenderStore>(),
sp.GetRequiredService<BlockedDomainStore>(),
uidStore,
sp.GetRequiredService<EmailClassifier>(),
sp.GetRequiredService<ActivityLog>(),
capturedAccount,
opts.Monitoring,
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<InboxMonitorService>>());
monitors.Add(monitor);
return monitor;
});
}
// Force singleton creation so monitors list is populated before host starts
services.AddHostedService(sp =>
{
// Resolve all InboxMonitorService singletons
sp.GetServices<InboxMonitorService>();
return new MonitorHostedServiceRunner(monitors);
});
})
.Build();
// Trigger DI resolution of InboxMonitorService singletons
host.Services.GetServices<InboxMonitorService>();
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();
}
}
}
/// <summary>Bridges per-account InboxMonitorService instances into the hosted service pipeline.</summary>
sealed class MonitorHostedServiceRunner : IHostedService
{
private readonly List<InboxMonitorService> _monitors;
public MonitorHostedServiceRunner(List<InboxMonitorService> monitors)
=> _monitors = monitors;
public Task StartAsync(CancellationToken ct)
=> Task.WhenAll(_monitors.Select(m => m.StartAsync(ct)));
public Task StopAsync(CancellationToken ct)
=> Task.WhenAll(_monitors.Select(m => m.StopAsync(ct)));
}

View File

@@ -1,5 +1,6 @@
namespace SpamGuard.Services; namespace SpamGuard.Services;
using System.Text.Json;
using SpamGuard.Models; using SpamGuard.Models;
public sealed class ActivityLog public sealed class ActivityLog
@@ -7,13 +8,21 @@ public sealed class ActivityLog
private readonly List<ActivityEntry> _entries = new(); private readonly List<ActivityEntry> _entries = new();
private readonly object _lock = new(); private readonly object _lock = new();
private readonly int _maxEntries; private readonly int _maxEntries;
private readonly string? _filePath;
public event Action? EntryChanged;
public int TodayChecked => GetTodayCount(_ => true); public int TodayChecked => GetTodayCount(_ => true);
public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam); public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam);
public ActivityLog(int maxEntries = 500) public ActivityLog(int maxEntries = 500, string? dataDirectory = null)
{ {
_maxEntries = maxEntries; _maxEntries = maxEntries;
if (dataDirectory != null)
{
_filePath = Path.Combine(dataDirectory, "activity-log.json");
LoadFromDisk();
}
} }
public void Add(ActivityEntry entry) public void Add(ActivityEntry entry)
@@ -24,6 +33,8 @@ public sealed class ActivityLog
if (_entries.Count > _maxEntries) if (_entries.Count > _maxEntries)
_entries.RemoveAt(0); _entries.RemoveAt(0);
} }
SaveToDisk();
EntryChanged?.Invoke();
} }
public List<ActivityEntry> GetRecent(int count = 100) public List<ActivityEntry> GetRecent(int count = 100)
@@ -37,6 +48,29 @@ public sealed class ActivityLog
} }
} }
public ActivityEntry? GetByIndex(int displayIndex, int displayCount)
{
lock (_lock)
{
var ordered = _entries
.OrderByDescending(e => e.Timestamp)
.Take(displayCount)
.ToList();
return displayIndex >= 0 && displayIndex < ordered.Count ? ordered[displayIndex] : null;
}
}
public void UpdateEntry(ActivityEntry entry, Verdict newVerdict, UserOverride userOverride)
{
lock (_lock)
{
entry.Verdict = newVerdict;
entry.Override = userOverride;
}
SaveToDisk();
EntryChanged?.Invoke();
}
private int GetTodayCount(Func<ActivityEntry, bool> predicate) private int GetTodayCount(Func<ActivityEntry, bool> predicate)
{ {
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@@ -45,4 +79,39 @@ public sealed class ActivityLog
return _entries.Count(e => e.Timestamp.Date == today && predicate(e)); return _entries.Count(e => e.Timestamp.Date == today && predicate(e));
} }
} }
private void SaveToDisk()
{
if (_filePath == null) return;
lock (_lock)
{
var json = JsonSerializer.Serialize(_entries, new JsonSerializerOptions { WriteIndented = true });
var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
}
}
private void LoadFromDisk()
{
if (_filePath == null || !File.Exists(_filePath)) return;
try
{
var json = File.ReadAllText(_filePath);
var entries = JsonSerializer.Deserialize<List<ActivityEntry>>(json);
if (entries != null)
{
lock (_lock)
{
_entries.AddRange(entries);
while (_entries.Count > _maxEntries)
_entries.RemoveAt(0);
}
}
}
catch
{
// Corrupted file -- start fresh
}
}
} }

View File

@@ -0,0 +1,167 @@
// 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;
using SpamGuard.State;
public sealed partial class EmailClassifier
{
private readonly SpamGuardOptions _options;
private readonly ILogger<EmailClassifier> _logger;
private readonly HttpClient _httpClient;
private readonly OverrideStore _overrideStore;
private const string BaseSystemPrompt = """
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 did not 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
YOU MUST respond with ONLY a JSON object. No explanation, no preamble, no markdown fencing.
Output exactly this structure and nothing else:
{"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"}
""";
public EmailClassifier(
IOptions<SpamGuardOptions> options,
ILogger<EmailClassifier> logger,
HttpClient httpClient,
OverrideStore overrideStore)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClient;
_overrideStore = overrideStore;
}
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 systemPrompt = BaseSystemPrompt + _overrideStore.BuildFewShotText(10);
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")
};
var isAnthropic = _options.Claude.BaseUrl.Contains("anthropic.com", StringComparison.OrdinalIgnoreCase);
if (isAnthropic)
{
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
request.Headers.Add("anthropic-version", "2023-06-01");
}
else
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.Claude.ApiKey);
}
var response = await _httpClient.SendAsync(request, ct);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Claude API returned {StatusCode}: {Error}", (int)response.StatusCode, errorBody);
return null;
}
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 direct parse first (happy path)
var result = TryParseJson(cleaned);
if (result != null) return result;
// Fall back: extract first {...} block from prose-wrapped responses
var start = cleaned.IndexOf('{');
var end = cleaned.LastIndexOf('}');
if (start >= 0 && end > start)
return TryParseJson(cleaned[start..(end + 1)]);
return null;
}
private static ClassificationResult? TryParseJson(string json)
{
try
{
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new ClassificationResult(
Classification: root.GetProperty("classification").GetString() ?? "unknown",
Confidence: root.GetProperty("confidence").GetDouble(),
Reason: root.GetProperty("reason").GetString() ?? ""
);
}
catch
{
return null;
}
}
[GeneratedRegex(@"```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.Compiled)]
private static partial Regex StripMarkdownFencing();
}

View File

@@ -0,0 +1,30 @@
namespace SpamGuard.Services;
using MailKit.Net.Imap;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using SpamGuard.Configuration;
public sealed class ImapClientFactory
{
private readonly ILogger<ImapClientFactory> _logger;
public ImapClientFactory(ILogger<ImapClientFactory> logger)
{
_logger = logger;
}
public async Task<ImapClient> CreateConnectedClientAsync(ImapOptions imap, CancellationToken ct = default)
{
var client = new ImapClient();
_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;
}
}

View File

@@ -0,0 +1,403 @@
// src/SpamGuard/Services/InboxMonitorService.cs
namespace SpamGuard.Services;
using MailKit;
using MailKit.Search;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
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 BlockedDomainStore _blockedDomains;
private readonly ProcessedUidStore _processedUids;
private readonly EmailClassifier _classifier;
private readonly ActivityLog _activityLog;
private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused;
private uint _lastSeenUid;
public string AccountName => _imap.Name.Length > 0 ? _imap.Name : _imap.Username;
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public InboxMonitorService(
ImapClientFactory imapFactory,
TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
ProcessedUidStore processedUids,
EmailClassifier classifier,
ActivityLog activityLog,
ImapOptions imap,
MonitoringOptions monitoring,
ILogger<InboxMonitorService> logger)
{
_imapFactory = imapFactory;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_processedUids = processedUids;
_classifier = classifier;
_activityLog = activityLog;
_imap = imap;
_monitoring = monitoring;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("InboxMonitorService started for {Account}", AccountName);
// 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
{
Timestamp = DateTime.UtcNow, Sender = "", Subject = "",
Verdict = Verdict.Error, Reason = ex.Message, AccountName = AccountName
});
}
}
await Task.Delay(TimeSpan.FromSeconds(_monitoring.PollIntervalSeconds), stoppingToken);
}
}
private async Task PollInboxAsync(CancellationToken ct)
{
var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
try
{
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(-_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 (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = "", Subject = $"UID {uid.Id}",
Verdict = Verdict.Error, Reason = ex.Message,
Uid = uid.Id, AccountName = AccountName
});
_processedUids.Add(uid.Id);
// Reconnect if the server dropped the connection (e.g. unexpected response after MoveToAsync)
if (!client.IsConnected || ex is MailKit.Net.Imap.ImapProtocolException)
{
_logger.LogWarning("IMAP connection lost for {Account}, reconnecting", AccountName);
try { client.Dispose(); } catch { }
client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
spamFolder = await FindSpamFolderAsync(client, ct);
}
}
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
}
await client.DisconnectAsync(true, ct);
}
finally
{
client.Dispose();
}
}
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)";
var bodySnippet = ExtractBodySnippet(message);
// Check trusted senders
if (_trustedSenders.IsTrusted(from))
{
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Trusted, Uid = uid.Id, AccountName = AccountName,
BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id);
return;
}
// Check blocked domains
if (_blockedDomains.IsBlocked(from))
{
_logger.LogInformation("UID={Uid} from blocked domain {From}, marking as spam", uid.Id, from);
if (spamFolder != null)
await inbox.MoveToAsync(uid, spamFolder, ct);
else
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Spam, Confidence = 1.0, Reason = "Blocked domain",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id);
return;
}
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
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Error, Reason = "Classification failed",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id);
return;
}
if (result.IsSpam && result.Confidence >= _monitoring.SpamConfidenceThreshold)
{
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
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Spam, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
}
else if (result.IsSpam)
{
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Uncertain, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
}
else
{
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Legitimate, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
}
_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();
public async Task MoveToSpamAsync(uint uid, CancellationToken ct = default)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
var spamFolder = await FindSpamFolderAsync(client, ct);
if (spamFolder != null)
await inbox.MoveToAsync(new UniqueId(uid), spamFolder, ct);
else
await inbox.AddFlagsAsync(new UniqueId(uid), MailKit.MessageFlags.Flagged, true, ct);
await client.DisconnectAsync(true, ct);
}
public async Task MoveToInboxAsync(uint uid, CancellationToken ct = default)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
// Find the spam folder and open it
var spamFolder = await FindSpamFolderAsync(client, ct);
if (spamFolder == null)
{
_logger.LogWarning("Cannot move to inbox: spam folder not found");
return;
}
await spamFolder.OpenAsync(FolderAccess.ReadWrite, ct);
var inbox = client.Inbox;
// Search for the UID in the spam folder
var uids = await spamFolder.SearchAsync(SearchQuery.All, ct);
var target = uids.FirstOrDefault(u => u.Id == uid);
if (target.IsValid)
await spamFolder.MoveToAsync(target, inbox, ct);
else
_logger.LogWarning("UID={Uid} not found in spam folder", uid);
await client.DisconnectAsync(true, ct);
}
private async Task<IMailFolder?> FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
{
// 1. Try IMAP special-use \Junk attribute (most reliable when server advertises it)
try
{
var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
if (junk != null)
{
_logger.LogDebug("Found spam folder via special-use attribute: {Full}", junk.FullName);
return junk;
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Server does not advertise Junk special-use attribute");
}
// 2. Search by name — try configured name plus common variants, two levels deep
var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
_monitoring.SpamFolderName, "Junk", "Spam", "Junk Email", "Junk E-mail", "Junk Mail", "Bulk Mail"
};
foreach (var ns in client.PersonalNamespaces)
{
IMailFolder root;
try { root = client.GetFolder(ns); }
catch { continue; }
IList<IMailFolder> topLevel;
try { topLevel = await root.GetSubfoldersAsync(false, ct); }
catch (Exception ex) { _logger.LogDebug(ex, "Could not list top-level folders"); continue; }
_logger.LogDebug("Folder list for {Account}: {Folders}",
AccountName, string.Join(", ", topLevel.Select(f => f.FullName)));
foreach (var folder in topLevel)
{
if (candidates.Contains(folder.Name))
{
_logger.LogDebug("Found spam folder: {Full}", folder.FullName);
return folder;
}
// One level deeper — catches [Gmail]/Spam, INBOX.Junk, etc.
try
{
var sub = await folder.GetSubfoldersAsync(false, ct);
if (sub.Count > 0)
_logger.LogDebug("Subfolders of {Parent}: {Subs}",
folder.FullName, string.Join(", ", sub.Select(f => f.FullName)));
var match = sub.FirstOrDefault(f => candidates.Contains(f.Name));
if (match != null)
{
_logger.LogDebug("Found nested spam folder: {Full}", match.FullName);
return match;
}
}
catch { /* skip non-enumerable folders */ }
}
}
_logger.LogWarning("Could not find spam/junk folder for account {Account} — will flag instead", AccountName);
return null;
}
}

View File

@@ -0,0 +1,137 @@
// src/SpamGuard/Services/TrustedSenderService.cs
namespace SpamGuard.Services;
using MailKit;
using MailKit.Search;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using MimeKit;
using SpamGuard.Configuration;
using SpamGuard.State;
public sealed class TrustedSenderService : BackgroundService
{
private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _store;
private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<TrustedSenderService> _logger;
private bool _initialScanDone;
public TrustedSenderService(
ImapClientFactory imapFactory,
TrustedSenderStore store,
ImapOptions imap,
MonitoringOptions monitoring,
ILogger<TrustedSenderService> logger)
{
_imapFactory = imapFactory;
_store = store;
_imap = imap;
_monitoring = monitoring;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("TrustedSenderService started for {Account}", _imap.Username);
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(_monitoring.TrustedSenderRefreshMinutes), stoppingToken);
}
}
private async Task ScanSentFolderAsync(CancellationToken ct)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
var sentFolder = await FindSentFolderAsync(client, ct)
?? 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(-_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 readonly string[] SentFolderNames = ["Sent", "Sent Items", "Sent Messages", "INBOX.Sent"];
private async Task<IMailFolder?> FindSentFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
{
// Try special folder first (requires SPECIAL-USE or XLIST extension)
try
{
var sent = client.GetFolder(MailKit.SpecialFolder.Sent);
if (sent != null) return sent;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "SPECIAL-USE not supported, falling back to folder name lookup");
}
// Fall back to well-known folder names
try
{
var personal = client.GetFolder(client.PersonalNamespaces[0]);
var folders = await personal.GetSubfoldersAsync(false, ct);
return folders.FirstOrDefault(f =>
SentFolderNames.Any(name => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not enumerate folders to find Sent folder");
}
return null;
}
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);
}
}
}
}
}

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
@@ -12,9 +12,18 @@
<PackageReference Include="MailKit" Version="4.*" /> <PackageReference Include="MailKit" Version="4.*" />
<PackageReference Include="Anthropic" Version="12.*" /> <PackageReference Include="Anthropic" Version="12.*" />
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" /> <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.Extensions.Hosting" Version="8.*" />
<PackageReference Include="Serilog.Sinks.File" Version="5.*" /> <PackageReference Include="Serilog.Sinks.File" Version="5.*" />
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" /> <PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project> </Project>

View File

@@ -0,0 +1,77 @@
namespace SpamGuard.State;
using System.Text.Json;
public sealed class BlockedDomainStore
{
private readonly string _filePath;
private readonly HashSet<string> _domains;
private readonly object _lock = new();
public BlockedDomainStore(string dataDirectory)
{
_filePath = Path.Combine(dataDirectory, "blocked-domains.json");
_domains = Load();
}
public int Count
{
get { lock (_lock) { return _domains.Count; } }
}
public bool IsBlocked(string email)
{
var domain = ExtractDomain(email);
if (string.IsNullOrEmpty(domain)) return false;
lock (_lock)
{
return _domains.Contains(domain);
}
}
public void Add(string domain)
{
lock (_lock)
{
_domains.Add(Normalize(domain));
}
}
public void Remove(string domain)
{
lock (_lock)
{
_domains.Remove(Normalize(domain));
}
}
public void Save()
{
lock (_lock)
{
var json = JsonSerializer.Serialize(_domains);
var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
}
}
public static string ExtractDomain(string email)
{
var at = email.IndexOf('@');
return at >= 0 ? Normalize(email[(at + 1)..]) : "";
}
private static string Normalize(string domain) => domain.Trim().ToLowerInvariant();
private HashSet<string> Load()
{
if (!File.Exists(_filePath))
return new HashSet<string>();
var json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<HashSet<string>>(json)
?? new HashSet<string>();
}
}

View File

@@ -0,0 +1,85 @@
namespace SpamGuard.State;
using System.Text;
using System.Text.Json;
public sealed record OverrideRecord(
DateTime Timestamp,
string Sender,
string Subject,
string? BodySnippet,
string UserVerdict,
string OriginalVerdict
);
public sealed class OverrideStore
{
private readonly string _filePath;
private readonly List<OverrideRecord> _overrides;
private readonly object _lock = new();
private const int MaxOverrides = 50;
public OverrideStore(string dataDirectory)
{
_filePath = Path.Combine(dataDirectory, "overrides.json");
_overrides = Load();
}
public void Add(OverrideRecord record)
{
lock (_lock)
{
_overrides.Add(record);
while (_overrides.Count > MaxOverrides)
_overrides.RemoveAt(0);
}
}
public List<OverrideRecord> GetRecent(int count = 10)
{
lock (_lock)
{
return _overrides
.OrderByDescending(o => o.Timestamp)
.Take(count)
.ToList();
}
}
public string BuildFewShotText(int count = 10)
{
var recent = GetRecent(count);
if (recent.Count == 0)
return "";
var sb = new StringBuilder();
sb.AppendLine();
sb.AppendLine("The user has previously corrected these classifications:");
foreach (var o in recent)
{
sb.AppendLine($"- [From: {o.Sender}, Subject: {o.Subject}] was originally classified as {o.OriginalVerdict} but the user marked it as {o.UserVerdict}.");
}
return sb.ToString();
}
public void Save()
{
lock (_lock)
{
var json = JsonSerializer.Serialize(_overrides, new JsonSerializerOptions { WriteIndented = true });
var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
}
}
private List<OverrideRecord> Load()
{
if (!File.Exists(_filePath))
return new List<OverrideRecord>();
var json = File.ReadAllText(_filePath);
return JsonSerializer.Deserialize<List<OverrideRecord>>(json)
?? new List<OverrideRecord>();
}
}

View File

@@ -9,9 +9,10 @@ public sealed class ProcessedUidStore
private readonly Dictionary<uint, DateTime> _uids; private readonly Dictionary<uint, DateTime> _uids;
private readonly object _lock = new(); private readonly object _lock = new();
public ProcessedUidStore(string dataDirectory) public ProcessedUidStore(string dataDirectory, string accountName)
{ {
_filePath = Path.Combine(dataDirectory, "processed-uids.json"); var safeName = string.Concat(accountName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
_filePath = Path.Combine(dataDirectory, $"processed-uids-{safeName}.json");
_uids = Load(); _uids = Load();
} }
@@ -52,7 +53,9 @@ public sealed class ProcessedUidStore
lock (_lock) lock (_lock)
{ {
var json = JsonSerializer.Serialize(_uids); var json = JsonSerializer.Serialize(_uids);
File.WriteAllText(_filePath, json); var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
} }
} }

View File

@@ -50,7 +50,9 @@ public sealed class TrustedSenderStore
lock (_lock) lock (_lock)
{ {
var json = JsonSerializer.Serialize(_senders); var json = JsonSerializer.Serialize(_senders);
File.WriteAllText(_filePath, json); var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
} }
} }

View File

@@ -0,0 +1,355 @@
namespace SpamGuard.Tray;
using SpamGuard.Models;
using SpamGuard.Services;
using SpamGuard.State;
public sealed class ActivityLogForm : Form
{
private readonly ActivityLog _activityLog;
private readonly IReadOnlyList<InboxMonitorService> _monitors;
private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly OverrideStore _overrideStore;
private readonly DataGridView _grid;
private readonly ToolStrip _toolbar;
private readonly ContextMenuStrip _rowMenu;
private const int DisplayCount = 200;
public ActivityLogForm(
ActivityLog activityLog,
IReadOnlyList<InboxMonitorService> monitors,
TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
OverrideStore overrideStore)
{
_activityLog = activityLog;
_monitors = monitors;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_overrideStore = overrideStore;
Text = "SpamGuard - Activity Log";
Size = new System.Drawing.Size(900, 550);
StartPosition = FormStartPosition.CenterScreen;
Icon = IconGenerator.Green;
KeyPreview = true;
// Toolbar
_toolbar = new ToolStrip();
var btnMarkSpam = new ToolStripButton("Mark Spam (S)")
{
ForeColor = System.Drawing.Color.Red,
Tag = "spam"
};
btnMarkSpam.Click += OnMarkSpam;
var btnMarkNotSpam = new ToolStripButton("Not Spam (H)")
{
ForeColor = System.Drawing.Color.Green,
Tag = "ham"
};
btnMarkNotSpam.Click += OnMarkNotSpam;
var btnBlockDomain = new ToolStripButton("Block Domain (D)")
{
ForeColor = System.Drawing.Color.DarkRed,
Tag = "block"
};
btnBlockDomain.Click += OnBlockDomain;
var btnAllowDomain = new ToolStripButton("Allow Domain")
{
ForeColor = System.Drawing.Color.DarkGreen,
Tag = "allow"
};
btnAllowDomain.Click += OnAllowDomain;
_toolbar.Items.AddRange(new ToolStripItem[]
{
btnMarkSpam,
btnMarkNotSpam,
new ToolStripSeparator(),
btnBlockDomain,
btnAllowDomain
});
// Context menu
_rowMenu = new ContextMenuStrip();
var ctxSpam = new ToolStripMenuItem("Mark as Spam (S)");
ctxSpam.Click += OnMarkSpam;
var ctxNotSpam = new ToolStripMenuItem("Mark as Not Spam (H)");
ctxNotSpam.Click += OnMarkNotSpam;
var ctxBlockDomain = new ToolStripMenuItem("Always Block Domain (D)");
ctxBlockDomain.Click += OnBlockDomain;
var ctxAllowDomain = new ToolStripMenuItem("Never Block Domain");
ctxAllowDomain.Click += OnAllowDomain;
_rowMenu.Items.AddRange(new ToolStripItem[]
{
ctxSpam, ctxNotSpam,
new ToolStripSeparator(),
ctxBlockDomain, ctxAllowDomain
});
// Grid
_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,
ContextMenuStrip = _rowMenu
};
_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.Add("Status", "");
_grid.Columns["Time"]!.Width = 70;
_grid.Columns["From"]!.Width = 150;
_grid.Columns["Verdict"]!.Width = 80;
_grid.Columns["Confidence"]!.Width = 80;
_grid.Columns["Status"]!.Width = 30;
_grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None;
Controls.Add(_grid);
Controls.Add(_toolbar);
// Subscribe to data changes — refresh only when something actually changes
_activityLog.EntryChanged += RefreshData;
RefreshData();
}
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (_grid.SelectedRows.Count == 0)
return base.ProcessCmdKey(ref msg, keyData);
switch (keyData)
{
case Keys.S:
OnMarkSpam(this, EventArgs.Empty);
return true;
case Keys.H:
OnMarkNotSpam(this, EventArgs.Empty);
return true;
case Keys.D:
OnBlockDomain(this, EventArgs.Empty);
return true;
default:
return base.ProcessCmdKey(ref msg, keyData);
}
}
private async void OnMarkSpam(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || entry.Uid == 0) return;
var monitor = FindMonitor(entry.AccountName);
if (monitor == null) return;
try
{
await monitor.MoveToSpamAsync(entry.Uid);
RecordOverride(entry, "spam");
_activityLog.UpdateEntry(entry, Verdict.Spam, UserOverride.MarkedSpam);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async void OnMarkNotSpam(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || entry.Uid == 0) return;
var monitor = FindMonitor(entry.AccountName);
if (monitor == null) return;
try
{
await monitor.MoveToInboxAsync(entry.Uid);
_trustedSenders.Add(entry.Sender);
_trustedSenders.Save();
RecordOverride(entry, "legitimate");
_activityLog.UpdateEntry(entry, Verdict.Legitimate, UserOverride.MarkedLegitimate);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void OnBlockDomain(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
if (string.IsNullOrEmpty(domain)) return;
_blockedDomains.Add(domain);
_blockedDomains.Save();
OnMarkSpam(sender, e);
}
private void OnAllowDomain(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
if (string.IsNullOrEmpty(domain)) return;
_blockedDomains.Remove(domain);
_blockedDomains.Save();
OnMarkNotSpam(sender, e);
}
private ActivityEntry? GetSelectedEntry()
{
if (_grid.SelectedRows.Count == 0) return null;
return _grid.SelectedRows[0].Tag as ActivityEntry;
}
private InboxMonitorService? FindMonitor(string accountName)
{
return _monitors.FirstOrDefault(m =>
m.AccountName.Equals(accountName, StringComparison.OrdinalIgnoreCase));
}
private void RecordOverride(ActivityEntry entry, string userVerdict)
{
var originalVerdict = entry.Verdict.ToString().ToLowerInvariant();
_overrideStore.Add(new OverrideRecord(
DateTime.UtcNow,
entry.Sender,
entry.Subject,
entry.BodySnippet,
userVerdict,
originalVerdict
));
_overrideStore.Save();
}
public void RefreshData()
{
if (InvokeRequired)
{
Invoke(RefreshData);
return;
}
// Preserve selection and scroll position across refresh
var selectedEntry = GetSelectedEntry();
var selectedUid = selectedEntry?.Uid ?? 0;
var selectedTimestamp = selectedEntry?.Timestamp ?? default;
var firstVisible = _grid.FirstDisplayedScrollingRowIndex;
var entries = _activityLog.GetRecent(DisplayCount);
_grid.SuspendLayout();
_grid.Rows.Clear();
int restoreIndex = -1;
foreach (var entry in entries)
{
var overrideMarker = entry.Override switch
{
UserOverride.MarkedSpam => "\u2716", // heavy X
UserOverride.MarkedLegitimate => "\u2714", // heavy checkmark
_ => ""
};
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 ?? "",
overrideMarker
);
var row = _grid.Rows[rowIndex];
row.Tag = entry;
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
};
if (entry.Override != null)
{
row.DefaultCellStyle.Font = new System.Drawing.Font(
_grid.DefaultCellStyle.Font ?? System.Drawing.SystemFonts.DefaultFont,
System.Drawing.FontStyle.Bold);
}
if (restoreIndex < 0)
{
if (selectedUid != 0 && entry.Uid == selectedUid)
restoreIndex = rowIndex;
else if (selectedUid == 0 && entry.Timestamp == selectedTimestamp)
restoreIndex = rowIndex;
}
}
// Restore scroll position
if (_grid.RowCount > 0 && firstVisible >= 0)
{
var clampedFirst = Math.Min(firstVisible, _grid.RowCount - 1);
_grid.FirstDisplayedScrollingRowIndex = clampedFirst;
}
// Restore selection without triggering a scroll jump
_grid.ClearSelection();
if (restoreIndex >= 0)
_grid.Rows[restoreIndex].Selected = true;
_grid.ResumeLayout();
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide();
}
else
{
base.OnFormClosing(e);
}
}
protected override void Dispose(bool disposing)
{
if (disposing)
_activityLog.EntryChanged -= RefreshData;
base.Dispose(disposing);
}
}

View 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;
}
}

View File

@@ -0,0 +1,121 @@
// src/SpamGuard/Tray/TrayApplicationContext.cs
namespace SpamGuard.Tray;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SpamGuard.Services;
using SpamGuard.State;
public sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly System.Windows.Forms.Timer _refreshTimer;
private readonly ActivityLog _activityLog;
private readonly IReadOnlyList<InboxMonitorService> _monitors;
private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly OverrideStore _overrideStore;
private readonly IHost _host;
private ActivityLogForm? _logForm;
private readonly ToolStripMenuItem _pauseMenuItem;
private bool AllPaused => _monitors.All(m => m.IsPaused);
public TrayApplicationContext(IHost host)
{
_host = host;
_activityLog = host.Services.GetRequiredService<ActivityLog>();
_monitors = host.Services.GetServices<InboxMonitorService>().ToList();
_trustedSenders = host.Services.GetRequiredService<TrustedSenderStore>();
_blockedDomains = host.Services.GetRequiredService<BlockedDomainStore>();
_overrideStore = host.Services.GetRequiredService<OverrideStore>();
_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;
// Timer only updates tray tooltip and icon — form refresh is event-driven
_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;
if (!AllPaused)
{
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;
}
// Form refresh removed — ActivityLogForm subscribes to ActivityLog.EntryChanged directly
}
private void OnViewLog(object? sender, EventArgs e)
{
if (_logForm == null || _logForm.IsDisposed)
{
_logForm = new ActivityLogForm(
_activityLog, _monitors, _trustedSenders, _blockedDomains, _overrideStore);
}
_logForm.Show();
_logForm.BringToFront();
}
private void OnPauseResume(object? sender, EventArgs e)
{
if (AllPaused)
{
foreach (var m in _monitors) m.Resume();
_pauseMenuItem.Text = "Pause";
_notifyIcon.Icon = IconGenerator.Green;
}
else
{
foreach (var m in _monitors) m.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);
}
}

View File

@@ -1,15 +1,27 @@
{ {
"SpamGuard": { "SpamGuard": {
"Imap": { "Accounts": [
"Host": "imap.example.com", {
"Port": 993, "Name": "Work",
"UseSsl": true, "Host": "imap.dynu.com",
"Username": "user@example.com", "Port": 993,
"Password": "" "UseSsl": true,
}, "Username": "peter.foster@ukdataservices.co.uk",
"Password": "Piglet69!"
},
{
"Name": "Personal",
"Host": "mail.hover.com",
"Port": 993,
"UseSsl": true,
"Username": "peter@foster.net",
"Password": "Piglet1969!!"
}
],
"Claude": { "Claude": {
"ApiKey": "", "ApiKey": "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",
"Model": "claude-sonnet-4-6", "BaseUrl": "https://openrouter.ai/api/",
"Model": "anthropic/claude-3.5-haiku",
"MaxBodyLength": 2000 "MaxBodyLength": 2000
}, },
"Monitoring": { "Monitoring": {

View 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
}
}
]
}
}

View File

@@ -0,0 +1,103 @@
// 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;
using SpamGuard.State;
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(),
new OverrideStore(Path.GetTempPath())
);
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(),
new OverrideStore(Path.GetTempPath())
);
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);
}
}

View File

@@ -12,7 +12,7 @@ public class ProcessedUidStoreTests : IDisposable
{ {
_tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}"); _tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir); Directory.CreateDirectory(_tempDir);
_store = new ProcessedUidStore(_tempDir); _store = new ProcessedUidStore(_tempDir, "TestAccount");
} }
public void Dispose() public void Dispose()
@@ -50,7 +50,7 @@ public class ProcessedUidStoreTests : IDisposable
_store.Add(3); _store.Add(3);
_store.Save(); _store.Save();
var loaded = new ProcessedUidStore(_tempDir); var loaded = new ProcessedUidStore(_tempDir, "TestAccount");
Assert.True(loaded.Contains(1)); Assert.True(loaded.Contains(1));
Assert.True(loaded.Contains(2)); Assert.True(loaded.Contains(2));
Assert.True(loaded.Contains(3)); Assert.True(loaded.Contains(3));

View File

@@ -1,10 +0,0 @@
namespace SpamGuard.Tests;
public class UnitTest1
{
[Fact]
public void Test1()
{
}
}