From 387c0dc155f6874093c336dd75a73325d2381028 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 12 Apr 2026 10:10:58 +0100 Subject: [PATCH] Commit pre-existing working changes (multi-account, blocked domains, overrides) --- .../Configuration/SpamGuardOptions.cs | 4 +- .../SpamGuardOptionsValidator.cs | 21 +++-- src/SpamGuard/Models/ActivityEntry.cs | 27 ++++-- src/SpamGuard/Program.cs | 84 ++++++++++++++---- src/SpamGuard/Services/ImapClientFactory.cs | 8 +- .../Services/TrustedSenderService.cs | 51 +++++++++-- src/SpamGuard/SpamGuard.csproj | 8 +- src/SpamGuard/State/BlockedDomainStore.cs | 77 +++++++++++++++++ src/SpamGuard/State/OverrideStore.cs | 85 +++++++++++++++++++ src/SpamGuard/State/ProcessedUidStore.cs | 5 +- src/SpamGuard/appsettings.json | 30 +++++-- .../State/ProcessedUidStoreTests.cs | 4 +- 12 files changed, 345 insertions(+), 59 deletions(-) create mode 100644 src/SpamGuard/State/BlockedDomainStore.cs create mode 100644 src/SpamGuard/State/OverrideStore.cs diff --git a/src/SpamGuard/Configuration/SpamGuardOptions.cs b/src/SpamGuard/Configuration/SpamGuardOptions.cs index a58d7f1..6084289 100644 --- a/src/SpamGuard/Configuration/SpamGuardOptions.cs +++ b/src/SpamGuard/Configuration/SpamGuardOptions.cs @@ -4,13 +4,14 @@ public sealed class SpamGuardOptions { public const string SectionName = "SpamGuard"; - public ImapOptions Imap { get; set; } = new(); + public List Accounts { get; set; } = new(); public ClaudeOptions Claude { get; set; } = new(); public MonitoringOptions Monitoring { get; set; } = new(); } public sealed class ImapOptions { + public string Name { get; set; } = ""; public string Host { get; set; } = ""; public int Port { get; set; } = 993; public bool UseSsl { get; set; } = true; @@ -21,6 +22,7 @@ public sealed class ImapOptions public sealed class ClaudeOptions { public string ApiKey { get; set; } = ""; + public string BaseUrl { get; set; } = "https://api.anthropic.com/"; public string Model { get; set; } = "claude-sonnet-4-6"; public int MaxBodyLength { get; set; } = 2000; } diff --git a/src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs b/src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs index af03dd5..b59b6aa 100644 --- a/src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs +++ b/src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs @@ -8,12 +8,21 @@ public sealed class SpamGuardOptionsValidator : IValidateOptions(); - 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 (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."); diff --git a/src/SpamGuard/Models/ActivityEntry.cs b/src/SpamGuard/Models/ActivityEntry.cs index 6d81e7a..63ef2b4 100644 --- a/src/SpamGuard/Models/ActivityEntry.cs +++ b/src/SpamGuard/Models/ActivityEntry.cs @@ -10,11 +10,22 @@ public enum Verdict Error } -public sealed record ActivityEntry( - DateTime Timestamp, - string Sender, - string Subject, - Verdict Verdict, - double? Confidence, - string? Reason -); +public enum UserOverride +{ + MarkedSpam, + MarkedLegitimate +} + +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; } +} diff --git a/src/SpamGuard/Program.cs b/src/SpamGuard/Program.cs index 179215f..ae5b7cb 100644 --- a/src/SpamGuard/Program.cs +++ b/src/SpamGuard/Program.cs @@ -8,6 +8,8 @@ using SpamGuard.Services; using SpamGuard.State; using SpamGuard.Tray; +#pragma warning disable CA1416 // Validate platform compatibility + namespace SpamGuard; static class Program @@ -52,32 +54,69 @@ static class Program services.AddSingleton, SpamGuardOptionsValidator>(); services.AddOptionsWithValidateOnStart(); - // State stores - services.AddSingleton(new ProcessedUidStore(dataDir)); - services.AddSingleton(new TrustedSenderStore(dataDir)); + var opts = context.Configuration + .GetSection(SpamGuardOptions.SectionName) + .Get()!; - // Services - services.AddSingleton(sp => - { - var opts = sp.GetRequiredService>().Value; - return new ActivityLog(opts.Monitoring.MaxActivityLogEntries); - }); + // 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(); - // EmailClassifier with managed HttpClient (timeout + base address) + // EmailClassifier with managed HttpClient services.AddHttpClient(client => { client.Timeout = TimeSpan.FromSeconds(30); - client.BaseAddress = new Uri("https://api.anthropic.com/"); + client.BaseAddress = new Uri(opts.Claude.BaseUrl); }); - // Background services - services.AddSingleton(); - services.AddHostedService(sp => sp.GetRequiredService()); - services.AddHostedService(); + // Per-account monitor + trusted sender service + var monitors = new List(); + 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(), + sp.GetRequiredService(), + account, + opts.Monitoring, + sp.GetRequiredService>())); + + var capturedAccount = account; + services.AddSingleton(sp => + { + var monitor = new InboxMonitorService( + sp.GetRequiredService(), + sp.GetRequiredService(), + sp.GetRequiredService(), + uidStore, + sp.GetRequiredService(), + sp.GetRequiredService(), + capturedAccount, + opts.Monitoring, + sp.GetRequiredService>()); + 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(); + return new MonitorHostedServiceRunner(monitors); + }); }) .Build(); + // Trigger DI resolution of InboxMonitorService singletons + host.Services.GetServices(); + host.Start(); Application.Run(new TrayApplicationContext(host)); @@ -94,3 +133,18 @@ static class Program } } } + +/// Bridges per-account InboxMonitorService instances into the hosted service pipeline. +sealed class MonitorHostedServiceRunner : IHostedService +{ + private readonly List _monitors; + + public MonitorHostedServiceRunner(List 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))); +} diff --git a/src/SpamGuard/Services/ImapClientFactory.cs b/src/SpamGuard/Services/ImapClientFactory.cs index 1b02584..4e605fd 100644 --- a/src/SpamGuard/Services/ImapClientFactory.cs +++ b/src/SpamGuard/Services/ImapClientFactory.cs @@ -3,24 +3,20 @@ 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 _logger; - public ImapClientFactory(IOptions options, ILogger logger) + public ImapClientFactory(ILogger logger) { - _options = options.Value; _logger = logger; } - public async Task CreateConnectedClientAsync(CancellationToken ct = default) + public async Task CreateConnectedClientAsync(ImapOptions imap, 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); diff --git a/src/SpamGuard/Services/TrustedSenderService.cs b/src/SpamGuard/Services/TrustedSenderService.cs index e03aaf5..5c28f4a 100644 --- a/src/SpamGuard/Services/TrustedSenderService.cs +++ b/src/SpamGuard/Services/TrustedSenderService.cs @@ -5,7 +5,6 @@ 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; @@ -14,25 +13,28 @@ public sealed class TrustedSenderService : BackgroundService { private readonly ImapClientFactory _imapFactory; private readonly TrustedSenderStore _store; - private readonly SpamGuardOptions _options; + private readonly ImapOptions _imap; + private readonly MonitoringOptions _monitoring; private readonly ILogger _logger; private bool _initialScanDone; public TrustedSenderService( ImapClientFactory imapFactory, TrustedSenderStore store, - IOptions options, + ImapOptions imap, + MonitoringOptions monitoring, ILogger logger) { _imapFactory = imapFactory; _store = store; - _options = options.Value; + _imap = imap; + _monitoring = monitoring; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("TrustedSenderService started"); + _logger.LogInformation("TrustedSenderService started for {Account}", _imap.Username); while (!stoppingToken.IsCancellationRequested) { @@ -51,22 +53,22 @@ public sealed class TrustedSenderService : BackgroundService _logger.LogError(ex, "Error scanning sent folder"); } - await Task.Delay(TimeSpan.FromMinutes(_options.Monitoring.TrustedSenderRefreshMinutes), stoppingToken); + await Task.Delay(TimeSpan.FromMinutes(_monitoring.TrustedSenderRefreshMinutes), stoppingToken); } } private async Task ScanSentFolderAsync(CancellationToken ct) { - using var client = await _imapFactory.CreateConnectedClientAsync(ct); + using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct); - var sentFolder = client.GetFolder(MailKit.SpecialFolder.Sent) + 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(-_options.Monitoring.TrustedSenderRefreshMinutes)) + ? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_monitoring.TrustedSenderRefreshMinutes)) : SearchQuery.All; var uids = await sentFolder.SearchAsync(query, ct); @@ -84,6 +86,37 @@ public sealed class TrustedSenderService : BackgroundService await client.DisconnectAsync(true, ct); } + private static readonly string[] SentFolderNames = ["Sent", "Sent Items", "Sent Messages", "INBOX.Sent"]; + + private async Task 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 addresses) { foreach (var headerName in new[] { "To", "Cc" }) diff --git a/src/SpamGuard/SpamGuard.csproj b/src/SpamGuard/SpamGuard.csproj index e4797c3..aa9e05a 100644 --- a/src/SpamGuard/SpamGuard.csproj +++ b/src/SpamGuard/SpamGuard.csproj @@ -1,4 +1,4 @@ - + WinExe @@ -20,4 +20,10 @@ + + + Always + + + diff --git a/src/SpamGuard/State/BlockedDomainStore.cs b/src/SpamGuard/State/BlockedDomainStore.cs new file mode 100644 index 0000000..743e5e7 --- /dev/null +++ b/src/SpamGuard/State/BlockedDomainStore.cs @@ -0,0 +1,77 @@ +namespace SpamGuard.State; + +using System.Text.Json; + +public sealed class BlockedDomainStore +{ + private readonly string _filePath; + private readonly HashSet _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 Load() + { + if (!File.Exists(_filePath)) + return new HashSet(); + + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize>(json) + ?? new HashSet(); + } +} diff --git a/src/SpamGuard/State/OverrideStore.cs b/src/SpamGuard/State/OverrideStore.cs new file mode 100644 index 0000000..a8888b6 --- /dev/null +++ b/src/SpamGuard/State/OverrideStore.cs @@ -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 _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 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 Load() + { + if (!File.Exists(_filePath)) + return new List(); + + var json = File.ReadAllText(_filePath); + return JsonSerializer.Deserialize>(json) + ?? new List(); + } +} diff --git a/src/SpamGuard/State/ProcessedUidStore.cs b/src/SpamGuard/State/ProcessedUidStore.cs index e582b59..98d6464 100644 --- a/src/SpamGuard/State/ProcessedUidStore.cs +++ b/src/SpamGuard/State/ProcessedUidStore.cs @@ -9,9 +9,10 @@ public sealed class ProcessedUidStore private readonly Dictionary _uids; 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(); } diff --git a/src/SpamGuard/appsettings.json b/src/SpamGuard/appsettings.json index ffcd56a..05de9b3 100644 --- a/src/SpamGuard/appsettings.json +++ b/src/SpamGuard/appsettings.json @@ -1,15 +1,27 @@ { "SpamGuard": { - "Imap": { - "Host": "imap.example.com", - "Port": 993, - "UseSsl": true, - "Username": "user@example.com", - "Password": "" - }, + "Accounts": [ + { + "Name": "Work", + "Host": "imap.dynu.com", + "Port": 993, + "UseSsl": true, + "Username": "peter.foster@ukdataservices.co.uk", + "Password": "Piglet69!" + }, + { + "Name": "Personal", + "Host": "mail.hover.com", + "Port": 993, + "UseSsl": true, + "Username": "peter@foster.net", + "Password": "Piglet1969!!" + } + ], "Claude": { - "ApiKey": "", - "Model": "claude-sonnet-4-6", + "ApiKey": "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", + "BaseUrl": "https://openrouter.ai/api/", + "Model": "anthropic/claude-3.5-haiku", "MaxBodyLength": 2000 }, "Monitoring": { diff --git a/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs b/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs index 23fb479..f637a7f 100644 --- a/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs +++ b/tests/SpamGuard.Tests/State/ProcessedUidStoreTests.cs @@ -12,7 +12,7 @@ public class ProcessedUidStoreTests : IDisposable { _tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}"); Directory.CreateDirectory(_tempDir); - _store = new ProcessedUidStore(_tempDir); + _store = new ProcessedUidStore(_tempDir, "TestAccount"); } public void Dispose() @@ -50,7 +50,7 @@ public class ProcessedUidStoreTests : IDisposable _store.Add(3); _store.Save(); - var loaded = new ProcessedUidStore(_tempDir); + var loaded = new ProcessedUidStore(_tempDir, "TestAccount"); Assert.True(loaded.Contains(1)); Assert.True(loaded.Contains(2)); Assert.True(loaded.Contains(3));