Compare commits

...

10 Commits

Author SHA1 Message Date
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
19 changed files with 1039 additions and 14 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

@@ -0,0 +1,24 @@
namespace SpamGuard.Configuration;
using Microsoft.Extensions.Options;
public sealed class SpamGuardOptionsValidator : IValidateOptions<SpamGuardOptions>
{
public ValidateOptionsResult Validate(string? name, SpamGuardOptions options)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.Imap.Host))
errors.Add("SpamGuard:Imap:Host is required.");
if (string.IsNullOrWhiteSpace(options.Imap.Username))
errors.Add("SpamGuard:Imap:Username is required.");
if (string.IsNullOrWhiteSpace(options.Imap.Password))
errors.Add("SpamGuard:Imap:Password is required.");
if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
errors.Add("SpamGuard:Claude:ApiKey is required.");
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}

View File

@@ -1,2 +1,96 @@
// 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;
namespace SpamGuard;
static class Program
{
[STAThread]
static void Main()
{
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
Application.SetHighDpiMode(HighDpiMode.SystemAware);
var dataDir = Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"SpamGuard");
Directory.CreateDirectory(dataDir);
Log.Logger = new LoggerConfiguration()
.WriteTo.File(
Path.Combine(dataDir, "logs", "spamguard-.log"),
rollingInterval: RollingInterval.Day,
retainedFileCountLimit: 7)
.CreateLogger();
IHost? host = null;
try
{
host = Host.CreateDefaultBuilder()
.UseSerilog()
.ConfigureAppConfiguration((context, config) =>
{
config.AddJsonFile(
Path.Combine(AppContext.BaseDirectory, "appsettings.json"),
optional: false);
config.AddEnvironmentVariables("SPAMGUARD_");
})
.ConfigureServices((context, services) =>
{
services.Configure<SpamGuardOptions>(
context.Configuration.GetSection(SpamGuardOptions.SectionName));
// Validate required configuration on startup
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
// State stores
services.AddSingleton(new ProcessedUidStore(dataDir));
services.AddSingleton(new TrustedSenderStore(dataDir));
// Services
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
});
services.AddSingleton<ImapClientFactory>();
// EmailClassifier with managed HttpClient (timeout + base address)
services.AddHttpClient<EmailClassifier>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.BaseAddress = new Uri("https://api.anthropic.com/");
});
// Background services
services.AddSingleton<InboxMonitorService>();
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>());
services.AddHostedService<TrustedSenderService>();
})
.Build();
host.Start();
Application.Run(new TrayApplicationContext(host));
}
catch (Exception ex)
{
Log.Fatal(ex, "Application failed to start");
}
finally
{
host?.StopAsync().GetAwaiter().GetResult();
host?.Dispose();
Log.CloseAndFlush();
}
}
}

View File

@@ -0,0 +1,130 @@
// src/SpamGuard/Services/EmailClassifier.cs
namespace SpamGuard.Services;
using System.Text;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SpamGuard.Configuration;
using SpamGuard.Models;
public sealed partial class EmailClassifier
{
private readonly SpamGuardOptions _options;
private readonly ILogger<EmailClassifier> _logger;
private readonly HttpClient _httpClient;
private const string SystemPrompt = """
You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate.
Spam includes:
- Unsolicited marketing or promotional emails the recipient never signed up for
- AI-generated emails designed to look like legitimate correspondence
- Newsletter signups the recipient didn't request
Legitimate includes:
- Emails from known contacts or businesses the recipient has a relationship with
- Transactional emails (receipts, shipping notifications, password resets)
- Emails the recipient would expect to receive
Respond with JSON only:
{"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"}
""";
public EmailClassifier(
IOptions<SpamGuardOptions> options,
ILogger<EmailClassifier> logger,
HttpClient httpClient)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClient;
}
public string BuildPrompt(EmailSummary email)
{
var body = email.BodySnippet.Length > _options.Claude.MaxBodyLength
? email.BodySnippet[.._options.Claude.MaxBodyLength]
: email.BodySnippet;
return $"""
Email details:
From: {email.From}
Subject: {email.Subject}
Body: {body}
""";
}
public async Task<ClassificationResult?> ClassifyAsync(EmailSummary email, CancellationToken ct = default)
{
var userMessage = BuildPrompt(email);
_logger.LogDebug("Classifying email UID={Uid} from {From}", email.Uid, email.From);
var requestBody = new
{
model = _options.Claude.Model,
max_tokens = 256,
system = SystemPrompt,
messages = new[]
{
new { role = "user", content = userMessage }
}
};
var json = JsonSerializer.Serialize(requestBody);
var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
request.Headers.Add("anthropic-version", "2023-06-01");
var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(responseJson);
var text = doc.RootElement
.GetProperty("content")[0]
.GetProperty("text")
.GetString() ?? "";
var result = ParseResponse(text);
if (result != null)
_logger.LogInformation(
"UID={Uid} classified as {Classification} (confidence={Confidence}): {Reason}",
email.Uid, result.Classification, result.Confidence, result.Reason);
else
_logger.LogWarning("UID={Uid} classification failed to parse: {Text}", email.Uid, text);
return result;
}
public static ClassificationResult? ParseResponse(string text)
{
// Strip markdown code fencing if present
var cleaned = StripMarkdownFencing().Replace(text, "$1").Trim();
try
{
using var doc = JsonDocument.Parse(cleaned);
var root = doc.RootElement;
return new ClassificationResult(
Classification: root.GetProperty("classification").GetString() ?? "unknown",
Confidence: root.GetProperty("confidence").GetDouble(),
Reason: root.GetProperty("reason").GetString() ?? ""
);
}
catch (Exception)
{
return null;
}
}
[GeneratedRegex(@"```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.Compiled)]
private static partial Regex StripMarkdownFencing();
}

View File

@@ -0,0 +1,34 @@
namespace SpamGuard.Services;
using MailKit.Net.Imap;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SpamGuard.Configuration;
public sealed class ImapClientFactory
{
private readonly SpamGuardOptions _options;
private readonly ILogger<ImapClientFactory> _logger;
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger)
{
_options = options.Value;
_logger = logger;
}
public async Task<ImapClient> CreateConnectedClientAsync(CancellationToken ct = default)
{
var client = new ImapClient();
var imap = _options.Imap;
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
var secureSocketOptions = imap.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable;
await client.ConnectAsync(imap.Host, imap.Port, secureSocketOptions, ct);
await client.AuthenticateAsync(imap.Username, imap.Password, ct);
_logger.LogDebug("Connected and authenticated as {Username}", imap.Username);
return client;
}
}

View File

@@ -0,0 +1,256 @@
// src/SpamGuard/Services/InboxMonitorService.cs
namespace SpamGuard.Services;
using MailKit;
using MailKit.Search;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using SpamGuard.Configuration;
using SpamGuard.Models;
using SpamGuard.State;
public sealed partial class InboxMonitorService : BackgroundService
{
private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _trustedSenders;
private readonly ProcessedUidStore _processedUids;
private readonly EmailClassifier _classifier;
private readonly ActivityLog _activityLog;
private readonly SpamGuardOptions _options;
private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused;
private uint _lastSeenUid;
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
public InboxMonitorService(
ImapClientFactory imapFactory,
TrustedSenderStore trustedSenders,
ProcessedUidStore processedUids,
EmailClassifier classifier,
ActivityLog activityLog,
IOptions<SpamGuardOptions> options,
ILogger<InboxMonitorService> logger)
{
_imapFactory = imapFactory;
_trustedSenders = trustedSenders;
_processedUids = processedUids;
_classifier = classifier;
_activityLog = activityLog;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("InboxMonitorService started");
// Brief delay to let TrustedSenderService do its first scan
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
if (!_paused)
{
try
{
await PollInboxAsync(stoppingToken);
_processedUids.Prune(TimeSpan.FromDays(30));
_processedUids.Save();
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error polling inbox");
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message));
}
}
await Task.Delay(TimeSpan.FromSeconds(_options.Monitoring.PollIntervalSeconds), stoppingToken);
}
}
private async Task PollInboxAsync(CancellationToken ct)
{
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
// Build search query: only fetch new messages
IList<UniqueId> uids;
if (_lastSeenUid > 0)
{
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
uids = await inbox.SearchAsync(range, SearchQuery.All, ct);
}
else if (_processedUids.Count > 0)
{
// Resuming from persisted state -- scan recent messages only
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-1)), ct);
}
else
{
// First ever run -- initial scan window
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct);
}
_logger.LogDebug("Found {Count} messages in inbox", uids.Count);
// Find the spam/junk folder
var spamFolder = await FindSpamFolderAsync(client, ct);
foreach (var uid in uids)
{
if (_processedUids.Contains(uid.Id))
{
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
continue;
}
try
{
await ProcessMessageAsync(inbox, uid, spamFolder, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message));
_processedUids.Add(uid.Id);
}
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
}
await client.DisconnectAsync(true, ct);
}
private async Task ProcessMessageAsync(
IMailFolder inbox, UniqueId uid, IMailFolder? spamFolder, CancellationToken ct)
{
var message = await inbox.GetMessageAsync(uid, ct);
var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown";
var subject = message.Subject ?? "(no subject)";
// Check trusted senders
if (_trustedSenders.IsTrusted(from))
{
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null));
_processedUids.Add(uid.Id);
return;
}
// Extract body snippet
var bodySnippet = ExtractBodySnippet(message);
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
// Classify
var result = await _classifier.ClassifyAsync(emailSummary, ct);
if (result == null)
{
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed"));
_processedUids.Add(uid.Id);
return;
}
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold)
{
// Move to spam folder
if (spamFolder != null)
{
await inbox.MoveToAsync(uid, spamFolder, ct);
_logger.LogInformation("Moved UID={Uid} to spam: {Reason}", uid.Id, result.Reason);
}
else
{
_logger.LogWarning("Spam detected but no spam folder found, flagging instead");
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
}
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason));
}
else if (result.IsSpam)
{
// Below threshold -- uncertain
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason));
}
else
{
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason));
}
_processedUids.Add(uid.Id);
}
private static string ExtractBodySnippet(MimeMessage message)
{
var text = message.TextBody;
if (text == null && message.HtmlBody != null)
{
// Strip script and style blocks first, then remaining tags
text = StripScriptStyle().Replace(message.HtmlBody, " ");
text = StripHtmlTags().Replace(text, " ");
text = System.Net.WebUtility.HtmlDecode(text);
text = CollapseWhitespace().Replace(text, " ").Trim();
}
text ??= "";
return text.Length > 2000 ? text[..2000] : text;
}
[System.Text.RegularExpressions.GeneratedRegex(@"<(script|style)[^>]*>[\s\S]*?</\1>", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex StripScriptStyle();
[System.Text.RegularExpressions.GeneratedRegex(@"<[^>]+>")]
private static partial System.Text.RegularExpressions.Regex StripHtmlTags();
[System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")]
private static partial System.Text.RegularExpressions.Regex CollapseWhitespace();
private async Task<IMailFolder?> FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
{
// Try special folder first
try
{
var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
if (junk != null) return junk;
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not get Junk special folder");
}
// Fall back to configured folder name
try
{
var personal = client.GetFolder(client.PersonalNamespaces[0]);
var folders = await personal.GetSubfoldersAsync(false, ct);
return folders.FirstOrDefault(f =>
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName);
}
return null;
}
}

View File

@@ -0,0 +1,104 @@
// src/SpamGuard/Services/TrustedSenderService.cs
namespace SpamGuard.Services;
using MailKit;
using MailKit.Search;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using SpamGuard.Configuration;
using SpamGuard.State;
public sealed class TrustedSenderService : BackgroundService
{
private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _store;
private readonly SpamGuardOptions _options;
private readonly ILogger<TrustedSenderService> _logger;
private bool _initialScanDone;
public TrustedSenderService(
ImapClientFactory imapFactory,
TrustedSenderStore store,
IOptions<SpamGuardOptions> options,
ILogger<TrustedSenderService> logger)
{
_imapFactory = imapFactory;
_store = store;
_options = options.Value;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("TrustedSenderService started");
while (!stoppingToken.IsCancellationRequested)
{
try
{
await ScanSentFolderAsync(stoppingToken);
_store.Save();
_logger.LogInformation("Trusted sender scan complete. {Count} trusted senders", _store.Count);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error scanning sent folder");
}
await Task.Delay(TimeSpan.FromMinutes(_options.Monitoring.TrustedSenderRefreshMinutes), stoppingToken);
}
}
private async Task ScanSentFolderAsync(CancellationToken ct)
{
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
var sentFolder = client.GetFolder(MailKit.SpecialFolder.Sent)
?? throw new InvalidOperationException("Could not find Sent folder");
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
// After initial full scan, only check messages from the last refresh period
var query = _initialScanDone
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_options.Monitoring.TrustedSenderRefreshMinutes))
: SearchQuery.All;
var uids = await sentFolder.SearchAsync(query, ct);
_logger.LogDebug("Found {Count} messages in Sent folder to scan", uids.Count);
var addresses = new List<string>();
foreach (var uid in uids)
{
var headers = await sentFolder.GetHeadersAsync(uid, ct);
ExtractAddresses(headers, addresses);
}
_store.AddRange(addresses);
_initialScanDone = true;
await client.DisconnectAsync(true, ct);
}
private static void ExtractAddresses(HeaderList headers, List<string> addresses)
{
foreach (var headerName in new[] { "To", "Cc" })
{
var value = headers[headerName];
if (string.IsNullOrEmpty(value)) continue;
if (InternetAddressList.TryParse(value, out var list))
{
foreach (var address in list.Mailboxes)
{
if (!string.IsNullOrEmpty(address.Address))
addresses.Add(address.Address);
}
}
}
}
}

View File

@@ -12,6 +12,9 @@
<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.*" />

View File

@@ -52,7 +52,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,95 @@
namespace SpamGuard.Tray;
using SpamGuard.Models;
using SpamGuard.Services;
public sealed class ActivityLogForm : Form
{
private readonly ActivityLog _activityLog;
private readonly DataGridView _grid;
public ActivityLogForm(ActivityLog activityLog)
{
_activityLog = activityLog;
Text = "SpamGuard - Activity Log";
Size = new System.Drawing.Size(800, 500);
StartPosition = FormStartPosition.CenterScreen;
Icon = IconGenerator.Green;
_grid = new DataGridView
{
Dock = DockStyle.Fill,
ReadOnly = true,
AllowUserToAddRows = false,
AllowUserToDeleteRows = false,
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
BackgroundColor = System.Drawing.SystemColors.Window,
BorderStyle = BorderStyle.None
};
_grid.Columns.Add("Time", "Time");
_grid.Columns.Add("From", "From");
_grid.Columns.Add("Subject", "Subject");
_grid.Columns.Add("Verdict", "Verdict");
_grid.Columns.Add("Confidence", "Confidence");
_grid.Columns.Add("Reason", "Reason");
_grid.Columns["Time"]!.Width = 70;
_grid.Columns["From"]!.Width = 150;
_grid.Columns["Verdict"]!.Width = 80;
_grid.Columns["Confidence"]!.Width = 80;
Controls.Add(_grid);
RefreshData();
}
public void RefreshData()
{
if (InvokeRequired)
{
Invoke(RefreshData);
return;
}
var entries = _activityLog.GetRecent(200);
_grid.Rows.Clear();
foreach (var entry in entries)
{
var rowIndex = _grid.Rows.Add(
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
entry.Sender,
entry.Subject,
entry.Verdict.ToString(),
entry.Confidence?.ToString("P0") ?? "--",
entry.Reason ?? ""
);
var row = _grid.Rows[rowIndex];
row.DefaultCellStyle.ForeColor = entry.Verdict switch
{
Verdict.Spam => System.Drawing.Color.Red,
Verdict.Trusted => System.Drawing.Color.Green,
Verdict.Uncertain => System.Drawing.Color.Orange,
Verdict.Error => System.Drawing.Color.Gray,
_ => System.Drawing.SystemColors.ControlText
};
}
}
protected override void OnFormClosing(FormClosingEventArgs e)
{
if (e.CloseReason == CloseReason.UserClosing)
{
e.Cancel = true;
Hide();
}
else
{
base.OnFormClosing(e);
}
}
}

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,111 @@
// src/SpamGuard/Tray/TrayApplicationContext.cs
namespace SpamGuard.Tray;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using SpamGuard.Services;
public sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly System.Windows.Forms.Timer _refreshTimer;
private readonly ActivityLog _activityLog;
private readonly InboxMonitorService _monitor;
private readonly IHost _host;
private ActivityLogForm? _logForm;
private readonly ToolStripMenuItem _pauseMenuItem;
public TrayApplicationContext(IHost host)
{
_host = host;
_activityLog = host.Services.GetRequiredService<ActivityLog>();
_monitor = host.Services.GetRequiredService<InboxMonitorService>();
_pauseMenuItem = new ToolStripMenuItem("Pause", null, OnPauseResume);
var contextMenu = new ContextMenuStrip();
contextMenu.Items.Add("View Activity Log", null, OnViewLog);
contextMenu.Items.Add(_pauseMenuItem);
contextMenu.Items.Add(new ToolStripSeparator());
contextMenu.Items.Add("Quit", null, OnQuit);
_notifyIcon = new NotifyIcon
{
Icon = IconGenerator.Green,
Text = "SpamGuard - Starting...",
Visible = true,
ContextMenuStrip = contextMenu
};
_notifyIcon.DoubleClick += OnViewLog;
_refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 };
_refreshTimer.Tick += OnRefreshTick;
_refreshTimer.Start();
}
private void OnRefreshTick(object? sender, EventArgs e)
{
var checked_ = _activityLog.TodayChecked;
var spam = _activityLog.TodaySpam;
var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
// Update icon based on state
if (!_monitor.IsPaused)
{
var recent = _activityLog.GetRecent(1);
var hasRecentError = recent.Count > 0
&& recent[0].Verdict == Models.Verdict.Error
&& recent[0].Timestamp > DateTime.UtcNow.AddMinutes(-5);
_notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green;
}
_logForm?.RefreshData();
}
private void OnViewLog(object? sender, EventArgs e)
{
if (_logForm == null || _logForm.IsDisposed)
{
_logForm = new ActivityLogForm(_activityLog);
}
_logForm.Show();
_logForm.BringToFront();
}
private void OnPauseResume(object? sender, EventArgs e)
{
if (_monitor.IsPaused)
{
_monitor.Resume();
_pauseMenuItem.Text = "Pause";
_notifyIcon.Icon = IconGenerator.Green;
}
else
{
_monitor.Pause();
_pauseMenuItem.Text = "Resume";
_notifyIcon.Icon = IconGenerator.Yellow;
}
}
private async void OnQuit(object? sender, EventArgs e)
{
_notifyIcon.Visible = false;
_refreshTimer.Stop();
await _host.StopAsync();
Application.Exit();
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_refreshTimer.Dispose();
_notifyIcon.Dispose();
_logForm?.Dispose();
}
base.Dispose(disposing);
}
}

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,100 @@
// tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
namespace SpamGuard.Tests.Services;
using System.Net;
using System.Text.Json;
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using SpamGuard.Configuration;
using SpamGuard.Models;
using SpamGuard.Services;
public class EmailClassifierTests
{
private static SpamGuardOptions DefaultOptions => new()
{
Claude = new ClaudeOptions
{
ApiKey = "test-key",
Model = "claude-sonnet-4-6",
MaxBodyLength = 2000
}
};
private static EmailSummary SampleEmail => new(
Uid: 1,
From: "spammer@sketchy.com",
Subject: "Buy now! Limited offer!",
BodySnippet: "Click here to claim your prize...",
Date: DateTimeOffset.UtcNow
);
[Fact]
public void BuildPrompt_ContainsSenderAndSubjectAndBody()
{
var classifier = new EmailClassifier(
Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(),
new HttpClient()
);
var prompt = classifier.BuildPrompt(SampleEmail);
Assert.Contains("spammer@sketchy.com", prompt);
Assert.Contains("Buy now! Limited offer!", prompt);
Assert.Contains("Click here to claim your prize...", prompt);
}
[Fact]
public void BuildPrompt_TruncatesLongBody()
{
var longBody = new string('x', 5000);
var email = SampleEmail with { BodySnippet = longBody };
var classifier = new EmailClassifier(
Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(),
new HttpClient()
);
var prompt = classifier.BuildPrompt(email);
// Body in prompt should be truncated to MaxBodyLength
Assert.DoesNotContain(longBody, prompt);
}
[Fact]
public void ParseResponse_ValidJson_ReturnsResult()
{
var json = """{"classification": "spam", "confidence": 0.95, "reason": "Unsolicited marketing"}""";
var result = EmailClassifier.ParseResponse(json);
Assert.NotNull(result);
Assert.True(result.IsSpam);
Assert.Equal(0.95, result.Confidence);
Assert.Equal("Unsolicited marketing", result.Reason);
}
[Fact]
public void ParseResponse_InvalidJson_ReturnsNull()
{
var result = EmailClassifier.ParseResponse("not json at all");
Assert.Null(result);
}
[Fact]
public void ParseResponse_JsonWithMarkdownFencing_ReturnsResult()
{
var json = """
```json
{"classification": "legitimate", "confidence": 0.85, "reason": "Normal business email"}
```
""";
var result = EmailClassifier.ParseResponse(json);
Assert.NotNull(result);
Assert.False(result.IsSpam);
}
}

View File

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