Commit pre-existing working changes (multi-account, blocked domains, overrides)
This commit is contained in:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.");
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
@@ -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" })
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
77
src/SpamGuard/State/BlockedDomainStore.cs
Normal file
77
src/SpamGuard/State/BlockedDomainStore.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/SpamGuard/State/OverrideStore.cs
Normal file
85
src/SpamGuard/State/OverrideStore.cs
Normal 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>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
Reference in New Issue
Block a user