Compare commits

..

16 Commits

Author SHA1 Message Date
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 1853 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,381 @@
// 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)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, 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(-_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
{
Timestamp = DateTime.UtcNow, Sender = "", Subject = $"UID {uid.Id}",
Verdict = Verdict.Error, Reason = ex.Message,
Uid = uid.Id, AccountName = AccountName
});
_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)";
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", {
"Name": "Work",
"Host": "imap.dynu.com",
"Port": 993, "Port": 993,
"UseSsl": true, "UseSsl": true,
"Username": "user@example.com", "Username": "peter.foster@ukdataservices.co.uk",
"Password": "" "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()
{
}
}