Commit pre-existing working changes (multi-account, blocked domains, overrides)

This commit is contained in:
2026-04-12 10:10:58 +01:00
parent 8aa35469fc
commit 387c0dc155
12 changed files with 345 additions and 59 deletions

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

@@ -8,12 +8,21 @@ public sealed class SpamGuardOptionsValidator : IValidateOptions<SpamGuardOption
{ {
var errors = new List<string>(); var errors = new List<string>();
if (string.IsNullOrWhiteSpace(options.Imap.Host)) if (options.Accounts.Count == 0)
errors.Add("SpamGuard:Imap:Host is required."); errors.Add("SpamGuard:Accounts must contain at least one account.");
if (string.IsNullOrWhiteSpace(options.Imap.Username))
errors.Add("SpamGuard:Imap:Username is required."); for (int i = 0; i < options.Accounts.Count; i++)
if (string.IsNullOrWhiteSpace(options.Imap.Password)) {
errors.Add("SpamGuard:Imap:Password is required."); 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)) if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
errors.Add("SpamGuard:Claude:ApiKey is required."); errors.Add("SpamGuard:Claude:ApiKey is required.");

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

@@ -8,6 +8,8 @@ using SpamGuard.Services;
using SpamGuard.State; using SpamGuard.State;
using SpamGuard.Tray; using SpamGuard.Tray;
#pragma warning disable CA1416 // Validate platform compatibility
namespace SpamGuard; namespace SpamGuard;
static class Program static class Program
@@ -52,32 +54,69 @@ static class Program
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>(); services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
services.AddOptionsWithValidateOnStart<SpamGuardOptions>(); services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
// State stores var opts = context.Configuration
services.AddSingleton(new ProcessedUidStore(dataDir)); .GetSection(SpamGuardOptions.SectionName)
services.AddSingleton(new TrustedSenderStore(dataDir)); .Get<SpamGuardOptions>()!;
// Services // Shared state / services
services.AddSingleton(sp => services.AddSingleton(new TrustedSenderStore(dataDir));
{ services.AddSingleton(new BlockedDomainStore(dataDir));
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value; services.AddSingleton(new OverrideStore(dataDir));
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries); services.AddSingleton(new ActivityLog(opts.Monitoring.MaxActivityLogEntries, dataDir));
});
services.AddSingleton<ImapClientFactory>(); services.AddSingleton<ImapClientFactory>();
// EmailClassifier with managed HttpClient (timeout + base address) // EmailClassifier with managed HttpClient
services.AddHttpClient<EmailClassifier>(client => services.AddHttpClient<EmailClassifier>(client =>
{ {
client.Timeout = TimeSpan.FromSeconds(30); client.Timeout = TimeSpan.FromSeconds(30);
client.BaseAddress = new Uri("https://api.anthropic.com/"); client.BaseAddress = new Uri(opts.Claude.BaseUrl);
}); });
// Background services // Per-account monitor + trusted sender service
services.AddSingleton<InboxMonitorService>(); var monitors = new List<InboxMonitorService>();
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>()); foreach (var account in opts.Accounts)
services.AddHostedService<TrustedSenderService>(); {
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(); .Build();
// Trigger DI resolution of InboxMonitorService singletons
host.Services.GetServices<InboxMonitorService>();
host.Start(); host.Start();
Application.Run(new TrayApplicationContext(host)); Application.Run(new TrayApplicationContext(host));
@@ -94,3 +133,18 @@ static class Program
} }
} }
} }
/// <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

@@ -3,24 +3,20 @@ namespace SpamGuard.Services;
using MailKit.Net.Imap; using MailKit.Net.Imap;
using MailKit.Security; using MailKit.Security;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SpamGuard.Configuration; using SpamGuard.Configuration;
public sealed class ImapClientFactory public sealed class ImapClientFactory
{ {
private readonly SpamGuardOptions _options;
private readonly ILogger<ImapClientFactory> _logger; private readonly ILogger<ImapClientFactory> _logger;
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger) public ImapClientFactory(ILogger<ImapClientFactory> logger)
{ {
_options = options.Value;
_logger = logger; _logger = logger;
} }
public async Task<ImapClient> CreateConnectedClientAsync(CancellationToken ct = default) public async Task<ImapClient> CreateConnectedClientAsync(ImapOptions imap, CancellationToken ct = default)
{ {
var client = new ImapClient(); var client = new ImapClient();
var imap = _options.Imap;
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl); _logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);

View File

@@ -5,7 +5,6 @@ using MailKit;
using MailKit.Search; using MailKit.Search;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit; using MimeKit;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.State; using SpamGuard.State;
@@ -14,25 +13,28 @@ public sealed class TrustedSenderService : BackgroundService
{ {
private readonly ImapClientFactory _imapFactory; private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _store; private readonly TrustedSenderStore _store;
private readonly SpamGuardOptions _options; private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<TrustedSenderService> _logger; private readonly ILogger<TrustedSenderService> _logger;
private bool _initialScanDone; private bool _initialScanDone;
public TrustedSenderService( public TrustedSenderService(
ImapClientFactory imapFactory, ImapClientFactory imapFactory,
TrustedSenderStore store, TrustedSenderStore store,
IOptions<SpamGuardOptions> options, ImapOptions imap,
MonitoringOptions monitoring,
ILogger<TrustedSenderService> logger) ILogger<TrustedSenderService> logger)
{ {
_imapFactory = imapFactory; _imapFactory = imapFactory;
_store = store; _store = store;
_options = options.Value; _imap = imap;
_monitoring = monitoring;
_logger = logger; _logger = logger;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("TrustedSenderService started"); _logger.LogInformation("TrustedSenderService started for {Account}", _imap.Username);
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
@@ -51,22 +53,22 @@ public sealed class TrustedSenderService : BackgroundService
_logger.LogError(ex, "Error scanning sent folder"); _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) 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"); ?? throw new InvalidOperationException("Could not find Sent folder");
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct); await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
// After initial full scan, only check messages from the last refresh period // After initial full scan, only check messages from the last refresh period
var query = _initialScanDone var query = _initialScanDone
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_options.Monitoring.TrustedSenderRefreshMinutes)) ? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_monitoring.TrustedSenderRefreshMinutes))
: SearchQuery.All; : SearchQuery.All;
var uids = await sentFolder.SearchAsync(query, ct); var uids = await sentFolder.SearchAsync(query, ct);
@@ -84,6 +86,37 @@ public sealed class TrustedSenderService : BackgroundService
await client.DisconnectAsync(true, ct); 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) private static void ExtractAddresses(HeaderList headers, List<string> addresses)
{ {
foreach (var headerName in new[] { "To", "Cc" }) foreach (var headerName in new[] { "To", "Cc" })

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
@@ -20,4 +20,10 @@
<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();
} }

View File

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

View File

@@ -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));