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>
This commit is contained in:
2026-04-07 11:57:12 +01:00
parent cd9adc5a54
commit 66cca61b21
12 changed files with 181 additions and 45 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,7 +1,7 @@
// src/SpamGuard/Program.cs
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using Serilog; using Serilog;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.Services; using SpamGuard.Services;
@@ -31,13 +31,16 @@ static class Program
retainedFileCountLimit: 7) retainedFileCountLimit: 7)
.CreateLogger(); .CreateLogger();
IHost? host = null;
try try
{ {
var host = Host.CreateDefaultBuilder() host = Host.CreateDefaultBuilder()
.UseSerilog() .UseSerilog()
.ConfigureAppConfiguration((context, config) => .ConfigureAppConfiguration((context, config) =>
{ {
config.AddJsonFile("appsettings.json", optional: false); config.AddJsonFile(
Path.Combine(AppContext.BaseDirectory, "appsettings.json"),
optional: false);
config.AddEnvironmentVariables("SPAMGUARD_"); config.AddEnvironmentVariables("SPAMGUARD_");
}) })
.ConfigureServices((context, services) => .ConfigureServices((context, services) =>
@@ -45,15 +48,28 @@ static class Program
services.Configure<SpamGuardOptions>( services.Configure<SpamGuardOptions>(
context.Configuration.GetSection(SpamGuardOptions.SectionName)); context.Configuration.GetSection(SpamGuardOptions.SectionName));
// Validate required configuration on startup
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
// State stores // State stores
services.AddSingleton(new ProcessedUidStore(dataDir)); services.AddSingleton(new ProcessedUidStore(dataDir));
services.AddSingleton(new TrustedSenderStore(dataDir)); services.AddSingleton(new TrustedSenderStore(dataDir));
// Services // Services
services.AddSingleton<ActivityLog>(); services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
});
services.AddSingleton<ImapClientFactory>(); services.AddSingleton<ImapClientFactory>();
services.AddSingleton<EmailClassifier>();
services.AddHttpClient<EmailClassifier>(); // 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 // Background services
services.AddSingleton<InboxMonitorService>(); services.AddSingleton<InboxMonitorService>();
@@ -72,6 +88,8 @@ static class Program
} }
finally finally
{ {
host?.StopAsync().GetAwaiter().GetResult();
host?.Dispose();
Log.CloseAndFlush(); Log.CloseAndFlush();
} }
} }

View File

@@ -74,7 +74,7 @@ public sealed partial class EmailClassifier
}; };
var json = JsonSerializer.Serialize(requestBody); var json = JsonSerializer.Serialize(requestBody);
var request = new HttpRequestMessage(HttpMethod.Post, "https://api.anthropic.com/v1/messages") var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
{ {
Content = new StringContent(json, Encoding.UTF8, "application/json") Content = new StringContent(json, Encoding.UTF8, "application/json")
}; };
@@ -85,7 +85,7 @@ public sealed partial class EmailClassifier
response.EnsureSuccessStatusCode(); response.EnsureSuccessStatusCode();
var responseJson = await response.Content.ReadAsStringAsync(ct); var responseJson = await response.Content.ReadAsStringAsync(ct);
var doc = JsonDocument.Parse(responseJson); using var doc = JsonDocument.Parse(responseJson);
var text = doc.RootElement var text = doc.RootElement
.GetProperty("content")[0] .GetProperty("content")[0]
.GetProperty("text") .GetProperty("text")
@@ -110,7 +110,7 @@ public sealed partial class EmailClassifier
try try
{ {
var doc = JsonDocument.Parse(cleaned); using var doc = JsonDocument.Parse(cleaned);
var root = doc.RootElement; var root = doc.RootElement;
return new ClassificationResult( return new ClassificationResult(

View File

@@ -11,7 +11,7 @@ using SpamGuard.Configuration;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.State; using SpamGuard.State;
public sealed class InboxMonitorService : BackgroundService public sealed partial class InboxMonitorService : BackgroundService
{ {
private readonly ImapClientFactory _imapFactory; private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _trustedSenders; private readonly TrustedSenderStore _trustedSenders;
@@ -22,6 +22,7 @@ public sealed class InboxMonitorService : BackgroundService
private readonly ILogger<InboxMonitorService> _logger; private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused; private volatile bool _paused;
private uint _lastSeenUid;
public bool IsPaused => _paused; public bool IsPaused => _paused;
public void Pause() => _paused = true; public void Pause() => _paused = true;
@@ -84,12 +85,25 @@ public sealed class InboxMonitorService : BackgroundService
var inbox = client.Inbox; var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct); await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
// Build search query: recent messages only // Build search query: only fetch new messages
var since = _processedUids.Count == 0 IList<UniqueId> uids;
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)) if (_lastSeenUid > 0)
: SearchQuery.All; {
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
var uids = await inbox.SearchAsync(since, ct); 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); _logger.LogDebug("Found {Count} messages in inbox", uids.Count);
// Find the spam/junk folder // Find the spam/junk folder
@@ -98,7 +112,10 @@ public sealed class InboxMonitorService : BackgroundService
foreach (var uid in uids) foreach (var uid in uids)
{ {
if (_processedUids.Contains(uid.Id)) if (_processedUids.Contains(uid.Id))
{
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
continue; continue;
}
try try
{ {
@@ -109,8 +126,10 @@ public sealed class InboxMonitorService : BackgroundService
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id); _logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry(
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message)); DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message));
_processedUids.Add(uid.Id); // Skip on next run to avoid infinite retry _processedUids.Add(uid.Id);
} }
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
} }
await client.DisconnectAsync(true, ct); await client.DisconnectAsync(true, ct);
@@ -182,15 +201,30 @@ public sealed class InboxMonitorService : BackgroundService
private static string ExtractBodySnippet(MimeMessage message) private static string ExtractBodySnippet(MimeMessage message)
{ {
var text = message.TextBody ?? message.HtmlBody ?? ""; var text = message.TextBody;
// Strip HTML tags if we fell back to HTML body if (text == null && message.HtmlBody != null)
if (message.TextBody == null && message.HtmlBody != null) {
text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", " "); // 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; 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) private async Task<IMailFolder?> FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
{ {
// Try special folder first // Try special folder first
@@ -199,7 +233,10 @@ public sealed class InboxMonitorService : BackgroundService
var junk = client.GetFolder(MailKit.SpecialFolder.Junk); var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
if (junk != null) return junk; if (junk != null) return junk;
} }
catch { } catch (Exception ex)
{
_logger.LogDebug(ex, "Could not get Junk special folder");
}
// Fall back to configured folder name // Fall back to configured folder name
try try
@@ -209,7 +246,10 @@ public sealed class InboxMonitorService : BackgroundService
return folders.FirstOrDefault(f => return folders.FirstOrDefault(f =>
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase)); f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase));
} }
catch { } catch (Exception ex)
{
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName);
}
return null; return null;
} }

View File

@@ -16,6 +16,7 @@ public sealed class TrustedSenderService : BackgroundService
private readonly TrustedSenderStore _store; private readonly TrustedSenderStore _store;
private readonly SpamGuardOptions _options; private readonly SpamGuardOptions _options;
private readonly ILogger<TrustedSenderService> _logger; private readonly ILogger<TrustedSenderService> _logger;
private bool _initialScanDone;
public TrustedSenderService( public TrustedSenderService(
ImapClientFactory imapFactory, ImapClientFactory imapFactory,
@@ -63,8 +64,13 @@ public sealed class TrustedSenderService : BackgroundService
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct); await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
var uids = await sentFolder.SearchAsync(SearchQuery.All, ct); // After initial full scan, only check messages from the last refresh period
_logger.LogDebug("Found {Count} messages in Sent folder", uids.Count); 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>(); var addresses = new List<string>();
foreach (var uid in uids) foreach (var uid in uids)
@@ -74,6 +80,7 @@ public sealed class TrustedSenderService : BackgroundService
} }
_store.AddRange(addresses); _store.AddRange(addresses);
_initialScanDone = true;
await client.DisconnectAsync(true, ct); await client.DisconnectAsync(true, ct);
} }

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

@@ -2,10 +2,23 @@ namespace SpamGuard.Tray;
using System.Drawing; using System.Drawing;
using System.Drawing.Drawing2D; using System.Drawing.Drawing2D;
using System.Runtime.InteropServices;
public static class IconGenerator public static class IconGenerator
{ {
public static Icon CreateCircleIcon(Color color, int size = 16) 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 bitmap = new Bitmap(size, size);
using var graphics = Graphics.FromImage(bitmap); using var graphics = Graphics.FromImage(bitmap);
@@ -15,14 +28,12 @@ public static class IconGenerator
using var brush = new SolidBrush(color); using var brush = new SolidBrush(color);
graphics.FillEllipse(brush, 1, 1, size - 2, size - 2); graphics.FillEllipse(brush, 1, 1, size - 2, size - 2);
// Add a subtle border
using var pen = new Pen(Color.FromArgb(100, 0, 0, 0), 1); using var pen = new Pen(Color.FromArgb(100, 0, 0, 0), 1);
graphics.DrawEllipse(pen, 1, 1, size - 3, size - 3); graphics.DrawEllipse(pen, 1, 1, size - 3, size - 3);
return Icon.FromHandle(bitmap.GetHicon()); var hIcon = bitmap.GetHicon();
var icon = (Icon)Icon.FromHandle(hIcon).Clone();
DestroyIcon(hIcon);
return icon;
} }
public static Icon Green => CreateCircleIcon(Color.FromArgb(76, 175, 80));
public static Icon Yellow => CreateCircleIcon(Color.FromArgb(255, 193, 7));
public static Icon Red => CreateCircleIcon(Color.FromArgb(244, 67, 54));
} }

View File

@@ -48,7 +48,8 @@ public sealed class TrayApplicationContext : ApplicationContext
{ {
var checked_ = _activityLog.TodayChecked; var checked_ = _activityLog.TodayChecked;
var spam = _activityLog.TodaySpam; var spam = _activityLog.TodaySpam;
_notifyIcon.Text = $"SpamGuard - {checked_} checked, {spam} spam caught today"; var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
// Update icon based on state // Update icon based on state
if (!_monitor.IsPaused) if (!_monitor.IsPaused)

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

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