Compare commits

..

7 Commits

Author SHA1 Message Date
1c00512661 Improve IMAP reconnection and error handling in polling
Refactored PollInboxAsync to better handle IMAP connection drops and protocol errors by reconnecting and resuming processing as needed. Switched from using a using statement to explicit disposal in a finally block. Now logs and recovers from transient IMAP issues, and ensures proper cancellation handling by rethrowing OperationCanceledException. This increases service robustness and reliability.
2026-04-12 14:23:16 +01:00
387c0dc155 Commit pre-existing working changes (multi-account, blocked domains, overrides) 2026-04-12 10:10:58 +01:00
8aa35469fc Fix Verdict:Error from prose-wrapped AI responses 2026-04-12 10:08:57 +01:00
5c801cef4b Fix spam folder not found: add Junk Email to candidate names 2026-04-12 09:59:58 +01:00
b5f8b7300b Fix spam folder detection — search two levels deep, try common names 2026-04-12 09:50:24 +01:00
27c6d12183 Stop grid jumping: event-driven refresh instead of 5-second timer 2026-04-12 09:19:47 +01:00
4d8342b658 Fix activity log refresh losing scroll position and selection 2026-04-12 09:13:43 +01:00
18 changed files with 980 additions and 168 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

@@ -1,5 +1,6 @@
namespace SpamGuard.Services; namespace SpamGuard.Services;
using System.Text.Json;
using SpamGuard.Models; using SpamGuard.Models;
public sealed class ActivityLog public sealed class ActivityLog
@@ -7,13 +8,21 @@ public sealed class ActivityLog
private readonly List<ActivityEntry> _entries = new(); private readonly List<ActivityEntry> _entries = new();
private readonly object _lock = new(); private readonly object _lock = new();
private readonly int _maxEntries; private readonly int _maxEntries;
private readonly string? _filePath;
public event Action? EntryChanged;
public int TodayChecked => GetTodayCount(_ => true); public int TodayChecked => GetTodayCount(_ => true);
public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam); public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam);
public ActivityLog(int maxEntries = 500) public ActivityLog(int maxEntries = 500, string? dataDirectory = null)
{ {
_maxEntries = maxEntries; _maxEntries = maxEntries;
if (dataDirectory != null)
{
_filePath = Path.Combine(dataDirectory, "activity-log.json");
LoadFromDisk();
}
} }
public void Add(ActivityEntry entry) public void Add(ActivityEntry entry)
@@ -24,6 +33,8 @@ public sealed class ActivityLog
if (_entries.Count > _maxEntries) if (_entries.Count > _maxEntries)
_entries.RemoveAt(0); _entries.RemoveAt(0);
} }
SaveToDisk();
EntryChanged?.Invoke();
} }
public List<ActivityEntry> GetRecent(int count = 100) public List<ActivityEntry> GetRecent(int count = 100)
@@ -37,6 +48,29 @@ public sealed class ActivityLog
} }
} }
public ActivityEntry? GetByIndex(int displayIndex, int displayCount)
{
lock (_lock)
{
var ordered = _entries
.OrderByDescending(e => e.Timestamp)
.Take(displayCount)
.ToList();
return displayIndex >= 0 && displayIndex < ordered.Count ? ordered[displayIndex] : null;
}
}
public void UpdateEntry(ActivityEntry entry, Verdict newVerdict, UserOverride userOverride)
{
lock (_lock)
{
entry.Verdict = newVerdict;
entry.Override = userOverride;
}
SaveToDisk();
EntryChanged?.Invoke();
}
private int GetTodayCount(Func<ActivityEntry, bool> predicate) private int GetTodayCount(Func<ActivityEntry, bool> predicate)
{ {
var today = DateTime.UtcNow.Date; var today = DateTime.UtcNow.Date;
@@ -45,4 +79,39 @@ public sealed class ActivityLog
return _entries.Count(e => e.Timestamp.Date == today && predicate(e)); return _entries.Count(e => e.Timestamp.Date == today && predicate(e));
} }
} }
private void SaveToDisk()
{
if (_filePath == null) return;
lock (_lock)
{
var json = JsonSerializer.Serialize(_entries, new JsonSerializerOptions { WriteIndented = true });
var tempPath = _filePath + ".tmp";
File.WriteAllText(tempPath, json);
File.Move(tempPath, _filePath, overwrite: true);
}
}
private void LoadFromDisk()
{
if (_filePath == null || !File.Exists(_filePath)) return;
try
{
var json = File.ReadAllText(_filePath);
var entries = JsonSerializer.Deserialize<List<ActivityEntry>>(json);
if (entries != null)
{
lock (_lock)
{
_entries.AddRange(entries);
while (_entries.Count > _maxEntries)
_entries.RemoveAt(0);
}
}
}
catch
{
// Corrupted file -- start fresh
}
}
} }

View File

@@ -8,38 +8,43 @@ using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.State;
public sealed partial class EmailClassifier public sealed partial class EmailClassifier
{ {
private readonly SpamGuardOptions _options; private readonly SpamGuardOptions _options;
private readonly ILogger<EmailClassifier> _logger; private readonly ILogger<EmailClassifier> _logger;
private readonly HttpClient _httpClient; private readonly HttpClient _httpClient;
private readonly OverrideStore _overrideStore;
private const string SystemPrompt = """ private const string BaseSystemPrompt = """
You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate. You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate.
Spam includes: Spam includes:
- Unsolicited marketing or promotional emails the recipient never signed up for - Unsolicited marketing or promotional emails the recipient never signed up for
- AI-generated emails designed to look like legitimate correspondence - AI-generated emails designed to look like legitimate correspondence
- Newsletter signups the recipient didn't request - Newsletter signups the recipient did not request
Legitimate includes: Legitimate includes:
- Emails from known contacts or businesses the recipient has a relationship with - Emails from known contacts or businesses the recipient has a relationship with
- Transactional emails (receipts, shipping notifications, password resets) - Transactional emails (receipts, shipping notifications, password resets)
- Emails the recipient would expect to receive - Emails the recipient would expect to receive
Respond with JSON only: YOU MUST respond with ONLY a JSON object. No explanation, no preamble, no markdown fencing.
Output exactly this structure and nothing else:
{"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"} {"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"}
"""; """;
public EmailClassifier( public EmailClassifier(
IOptions<SpamGuardOptions> options, IOptions<SpamGuardOptions> options,
ILogger<EmailClassifier> logger, ILogger<EmailClassifier> logger,
HttpClient httpClient) HttpClient httpClient,
OverrideStore overrideStore)
{ {
_options = options.Value; _options = options.Value;
_logger = logger; _logger = logger;
_httpClient = httpClient; _httpClient = httpClient;
_overrideStore = overrideStore;
} }
public string BuildPrompt(EmailSummary email) public string BuildPrompt(EmailSummary email)
@@ -62,11 +67,13 @@ public sealed partial class EmailClassifier
_logger.LogDebug("Classifying email UID={Uid} from {From}", email.Uid, email.From); _logger.LogDebug("Classifying email UID={Uid} from {From}", email.Uid, email.From);
var systemPrompt = BaseSystemPrompt + _overrideStore.BuildFewShotText(10);
var requestBody = new var requestBody = new
{ {
model = _options.Claude.Model, model = _options.Claude.Model,
max_tokens = 256, max_tokens = 256,
system = SystemPrompt, system = systemPrompt,
messages = new[] messages = new[]
{ {
new { role = "user", content = userMessage } new { role = "user", content = userMessage }
@@ -78,11 +85,27 @@ public sealed partial class EmailClassifier
{ {
Content = new StringContent(json, Encoding.UTF8, "application/json") Content = new StringContent(json, Encoding.UTF8, "application/json")
}; };
var isAnthropic = _options.Claude.BaseUrl.Contains("anthropic.com", StringComparison.OrdinalIgnoreCase);
if (isAnthropic)
{
request.Headers.Add("x-api-key", _options.Claude.ApiKey); request.Headers.Add("x-api-key", _options.Claude.ApiKey);
request.Headers.Add("anthropic-version", "2023-06-01"); request.Headers.Add("anthropic-version", "2023-06-01");
}
else
{
request.Headers.Authorization =
new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", _options.Claude.ApiKey);
}
var response = await _httpClient.SendAsync(request, ct); var response = await _httpClient.SendAsync(request, ct);
response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync(ct);
_logger.LogError("Claude API returned {StatusCode}: {Error}", (int)response.StatusCode, errorBody);
return null;
}
var responseJson = await response.Content.ReadAsStringAsync(ct); var responseJson = await response.Content.ReadAsStringAsync(ct);
using var doc = JsonDocument.Parse(responseJson); using var doc = JsonDocument.Parse(responseJson);
@@ -108,18 +131,32 @@ public sealed partial class EmailClassifier
// Strip markdown code fencing if present // Strip markdown code fencing if present
var cleaned = StripMarkdownFencing().Replace(text, "$1").Trim(); var cleaned = StripMarkdownFencing().Replace(text, "$1").Trim();
// Try direct parse first (happy path)
var result = TryParseJson(cleaned);
if (result != null) return result;
// Fall back: extract first {...} block from prose-wrapped responses
var start = cleaned.IndexOf('{');
var end = cleaned.LastIndexOf('}');
if (start >= 0 && end > start)
return TryParseJson(cleaned[start..(end + 1)]);
return null;
}
private static ClassificationResult? TryParseJson(string json)
{
try try
{ {
using var doc = JsonDocument.Parse(cleaned); using var doc = JsonDocument.Parse(json);
var root = doc.RootElement; var root = doc.RootElement;
return new ClassificationResult( return new ClassificationResult(
Classification: root.GetProperty("classification").GetString() ?? "unknown", Classification: root.GetProperty("classification").GetString() ?? "unknown",
Confidence: root.GetProperty("confidence").GetDouble(), Confidence: root.GetProperty("confidence").GetDouble(),
Reason: root.GetProperty("reason").GetString() ?? "" Reason: root.GetProperty("reason").GetString() ?? ""
); );
} }
catch (Exception) catch
{ {
return null; return null;
} }

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.Models; using SpamGuard.Models;
@@ -15,15 +14,18 @@ public sealed partial class InboxMonitorService : BackgroundService
{ {
private readonly ImapClientFactory _imapFactory; private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _trustedSenders; private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly ProcessedUidStore _processedUids; private readonly ProcessedUidStore _processedUids;
private readonly EmailClassifier _classifier; private readonly EmailClassifier _classifier;
private readonly ActivityLog _activityLog; private readonly ActivityLog _activityLog;
private readonly SpamGuardOptions _options; private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<InboxMonitorService> _logger; private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused; private volatile bool _paused;
private uint _lastSeenUid; private uint _lastSeenUid;
public string AccountName => _imap.Name.Length > 0 ? _imap.Name : _imap.Username;
public bool IsPaused => _paused; public bool IsPaused => _paused;
public void Pause() => _paused = true; public void Pause() => _paused = true;
public void Resume() => _paused = false; public void Resume() => _paused = false;
@@ -31,24 +33,28 @@ public sealed partial class InboxMonitorService : BackgroundService
public InboxMonitorService( public InboxMonitorService(
ImapClientFactory imapFactory, ImapClientFactory imapFactory,
TrustedSenderStore trustedSenders, TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
ProcessedUidStore processedUids, ProcessedUidStore processedUids,
EmailClassifier classifier, EmailClassifier classifier,
ActivityLog activityLog, ActivityLog activityLog,
IOptions<SpamGuardOptions> options, ImapOptions imap,
MonitoringOptions monitoring,
ILogger<InboxMonitorService> logger) ILogger<InboxMonitorService> logger)
{ {
_imapFactory = imapFactory; _imapFactory = imapFactory;
_trustedSenders = trustedSenders; _trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_processedUids = processedUids; _processedUids = processedUids;
_classifier = classifier; _classifier = classifier;
_activityLog = activityLog; _activityLog = activityLog;
_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("InboxMonitorService started"); _logger.LogInformation("InboxMonitorService started for {Account}", AccountName);
// Brief delay to let TrustedSenderService do its first scan // Brief delay to let TrustedSenderService do its first scan
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
@@ -70,18 +76,23 @@ public sealed partial class InboxMonitorService : BackgroundService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error polling inbox"); _logger.LogError(ex, "Error polling inbox");
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message)); {
Timestamp = DateTime.UtcNow, Sender = "", Subject = "",
Verdict = Verdict.Error, Reason = ex.Message, AccountName = AccountName
});
} }
} }
await Task.Delay(TimeSpan.FromSeconds(_options.Monitoring.PollIntervalSeconds), stoppingToken); await Task.Delay(TimeSpan.FromSeconds(_monitoring.PollIntervalSeconds), stoppingToken);
} }
} }
private async Task PollInboxAsync(CancellationToken ct) private async Task PollInboxAsync(CancellationToken ct)
{ {
using var client = await _imapFactory.CreateConnectedClientAsync(ct); var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
try
{
var inbox = client.Inbox; var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct); await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
@@ -102,7 +113,7 @@ public sealed partial class InboxMonitorService : BackgroundService
{ {
// First ever run -- initial scan window // First ever run -- initial scan window
uids = await inbox.SearchAsync( uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct); SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_monitoring.InitialScanDays)), ct);
} }
_logger.LogDebug("Found {Count} messages in inbox", uids.Count); _logger.LogDebug("Found {Count} messages in inbox", uids.Count);
@@ -121,12 +132,31 @@ public sealed partial class InboxMonitorService : BackgroundService
{ {
await ProcessMessageAsync(inbox, uid, spamFolder, ct); await ProcessMessageAsync(inbox, uid, spamFolder, ct);
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex) catch (Exception ex)
{ {
_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)); {
Timestamp = DateTime.UtcNow, Sender = "", Subject = $"UID {uid.Id}",
Verdict = Verdict.Error, Reason = ex.Message,
Uid = uid.Id, AccountName = AccountName
});
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
// Reconnect if the server dropped the connection (e.g. unexpected response after MoveToAsync)
if (!client.IsConnected || ex is MailKit.Net.Imap.ImapProtocolException)
{
_logger.LogWarning("IMAP connection lost for {Account}, reconnecting", AccountName);
try { client.Dispose(); } catch { }
client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
spamFolder = await FindSpamFolderAsync(client, ct);
}
} }
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id; if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
@@ -134,6 +164,11 @@ public sealed partial class InboxMonitorService : BackgroundService
await client.DisconnectAsync(true, ct); await client.DisconnectAsync(true, ct);
} }
finally
{
client.Dispose();
}
}
private async Task ProcessMessageAsync( private async Task ProcessMessageAsync(
IMailFolder inbox, UniqueId uid, IMailFolder? spamFolder, CancellationToken ct) IMailFolder inbox, UniqueId uid, IMailFolder? spamFolder, CancellationToken ct)
@@ -141,19 +176,41 @@ public sealed partial class InboxMonitorService : BackgroundService
var message = await inbox.GetMessageAsync(uid, ct); var message = await inbox.GetMessageAsync(uid, ct);
var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown"; var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown";
var subject = message.Subject ?? "(no subject)"; var subject = message.Subject ?? "(no subject)";
var bodySnippet = ExtractBodySnippet(message);
// Check trusted senders // Check trusted senders
if (_trustedSenders.IsTrusted(from)) if (_trustedSenders.IsTrusted(from))
{ {
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from); _logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null)); {
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Trusted, Uid = uid.Id, AccountName = AccountName,
BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id);
return;
}
// Check blocked domains
if (_blockedDomains.IsBlocked(from))
{
_logger.LogInformation("UID={Uid} from blocked domain {From}, marking as spam", uid.Id, from);
if (spamFolder != null)
await inbox.MoveToAsync(uid, spamFolder, ct);
else
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Spam, Confidence = 1.0, Reason = "Blocked domain",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
return; return;
} }
// Extract body snippet
var bodySnippet = ExtractBodySnippet(message);
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date); var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
// Classify // Classify
@@ -161,15 +218,18 @@ public sealed partial class InboxMonitorService : BackgroundService
if (result == null) if (result == null)
{ {
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed")); {
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Error, Reason = "Classification failed",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
return; return;
} }
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold) if (result.IsSpam && result.Confidence >= _monitoring.SpamConfidenceThreshold)
{ {
// Move to spam folder
if (spamFolder != null) if (spamFolder != null)
{ {
await inbox.MoveToAsync(uid, spamFolder, ct); await inbox.MoveToAsync(uid, spamFolder, ct);
@@ -181,19 +241,30 @@ public sealed partial class InboxMonitorService : BackgroundService
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct); await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
} }
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason)); {
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Spam, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
} }
else if (result.IsSpam) else if (result.IsSpam)
{ {
// Below threshold -- uncertain _activityLog.Add(new ActivityEntry
_activityLog.Add(new ActivityEntry( {
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason)); Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Uncertain, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
} }
else else
{ {
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason)); {
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Legitimate, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
} }
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
@@ -225,32 +296,108 @@ public sealed partial class InboxMonitorService : BackgroundService
[System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")] [System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")]
private static partial System.Text.RegularExpressions.Regex CollapseWhitespace(); private static partial System.Text.RegularExpressions.Regex CollapseWhitespace();
public async Task MoveToSpamAsync(uint uid, CancellationToken ct = default)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
var spamFolder = await FindSpamFolderAsync(client, ct);
if (spamFolder != null)
await inbox.MoveToAsync(new UniqueId(uid), spamFolder, ct);
else
await inbox.AddFlagsAsync(new UniqueId(uid), MailKit.MessageFlags.Flagged, true, ct);
await client.DisconnectAsync(true, ct);
}
public async Task MoveToInboxAsync(uint uid, CancellationToken ct = default)
{
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
// Find the spam folder and open it
var spamFolder = await FindSpamFolderAsync(client, ct);
if (spamFolder == null)
{
_logger.LogWarning("Cannot move to inbox: spam folder not found");
return;
}
await spamFolder.OpenAsync(FolderAccess.ReadWrite, ct);
var inbox = client.Inbox;
// Search for the UID in the spam folder
var uids = await spamFolder.SearchAsync(SearchQuery.All, ct);
var target = uids.FirstOrDefault(u => u.Id == uid);
if (target.IsValid)
await spamFolder.MoveToAsync(target, inbox, ct);
else
_logger.LogWarning("UID={Uid} not found in spam folder", uid);
await client.DisconnectAsync(true, ct);
}
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 // 1. Try IMAP special-use \Junk attribute (most reliable when server advertises it)
try try
{ {
var junk = client.GetFolder(MailKit.SpecialFolder.Junk); var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
if (junk != null) return junk; if (junk != null)
{
_logger.LogDebug("Found spam folder via special-use attribute: {Full}", junk.FullName);
return junk;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogDebug(ex, "Could not get Junk special folder"); _logger.LogDebug(ex, "Server does not advertise Junk special-use attribute");
} }
// Fall back to configured folder name // 2. Search by name — try configured name plus common variants, two levels deep
var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
_monitoring.SpamFolderName, "Junk", "Spam", "Junk Email", "Junk E-mail", "Junk Mail", "Bulk Mail"
};
foreach (var ns in client.PersonalNamespaces)
{
IMailFolder root;
try { root = client.GetFolder(ns); }
catch { continue; }
IList<IMailFolder> topLevel;
try { topLevel = await root.GetSubfoldersAsync(false, ct); }
catch (Exception ex) { _logger.LogDebug(ex, "Could not list top-level folders"); continue; }
_logger.LogDebug("Folder list for {Account}: {Folders}",
AccountName, string.Join(", ", topLevel.Select(f => f.FullName)));
foreach (var folder in topLevel)
{
if (candidates.Contains(folder.Name))
{
_logger.LogDebug("Found spam folder: {Full}", folder.FullName);
return folder;
}
// One level deeper — catches [Gmail]/Spam, INBOX.Junk, etc.
try try
{ {
var personal = client.GetFolder(client.PersonalNamespaces[0]); var sub = await folder.GetSubfoldersAsync(false, ct);
var folders = await personal.GetSubfoldersAsync(false, ct); if (sub.Count > 0)
return folders.FirstOrDefault(f => _logger.LogDebug("Subfolders of {Parent}: {Subs}",
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase)); folder.FullName, string.Join(", ", sub.Select(f => f.FullName)));
} var match = sub.FirstOrDefault(f => candidates.Contains(f.Name));
catch (Exception ex) if (match != null)
{ {
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName); _logger.LogDebug("Found nested spam folder: {Full}", match.FullName);
return match;
}
}
catch { /* skip non-enumerable folders */ }
}
} }
_logger.LogWarning("Could not find spam/junk folder for account {Account} — will flag instead", AccountName);
return null; return null;
} }
} }

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

@@ -2,21 +2,98 @@ namespace SpamGuard.Tray;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.Services; using SpamGuard.Services;
using SpamGuard.State;
public sealed class ActivityLogForm : Form public sealed class ActivityLogForm : Form
{ {
private readonly ActivityLog _activityLog; private readonly ActivityLog _activityLog;
private readonly IReadOnlyList<InboxMonitorService> _monitors;
private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly OverrideStore _overrideStore;
private readonly DataGridView _grid; private readonly DataGridView _grid;
private readonly ToolStrip _toolbar;
private readonly ContextMenuStrip _rowMenu;
public ActivityLogForm(ActivityLog activityLog) private const int DisplayCount = 200;
public ActivityLogForm(
ActivityLog activityLog,
IReadOnlyList<InboxMonitorService> monitors,
TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
OverrideStore overrideStore)
{ {
_activityLog = activityLog; _activityLog = activityLog;
_monitors = monitors;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_overrideStore = overrideStore;
Text = "SpamGuard - Activity Log"; Text = "SpamGuard - Activity Log";
Size = new System.Drawing.Size(800, 500); Size = new System.Drawing.Size(900, 550);
StartPosition = FormStartPosition.CenterScreen; StartPosition = FormStartPosition.CenterScreen;
Icon = IconGenerator.Green; Icon = IconGenerator.Green;
KeyPreview = true;
// Toolbar
_toolbar = new ToolStrip();
var btnMarkSpam = new ToolStripButton("Mark Spam (S)")
{
ForeColor = System.Drawing.Color.Red,
Tag = "spam"
};
btnMarkSpam.Click += OnMarkSpam;
var btnMarkNotSpam = new ToolStripButton("Not Spam (H)")
{
ForeColor = System.Drawing.Color.Green,
Tag = "ham"
};
btnMarkNotSpam.Click += OnMarkNotSpam;
var btnBlockDomain = new ToolStripButton("Block Domain (D)")
{
ForeColor = System.Drawing.Color.DarkRed,
Tag = "block"
};
btnBlockDomain.Click += OnBlockDomain;
var btnAllowDomain = new ToolStripButton("Allow Domain")
{
ForeColor = System.Drawing.Color.DarkGreen,
Tag = "allow"
};
btnAllowDomain.Click += OnAllowDomain;
_toolbar.Items.AddRange(new ToolStripItem[]
{
btnMarkSpam,
btnMarkNotSpam,
new ToolStripSeparator(),
btnBlockDomain,
btnAllowDomain
});
// Context menu
_rowMenu = new ContextMenuStrip();
var ctxSpam = new ToolStripMenuItem("Mark as Spam (S)");
ctxSpam.Click += OnMarkSpam;
var ctxNotSpam = new ToolStripMenuItem("Mark as Not Spam (H)");
ctxNotSpam.Click += OnMarkNotSpam;
var ctxBlockDomain = new ToolStripMenuItem("Always Block Domain (D)");
ctxBlockDomain.Click += OnBlockDomain;
var ctxAllowDomain = new ToolStripMenuItem("Never Block Domain");
ctxAllowDomain.Click += OnAllowDomain;
_rowMenu.Items.AddRange(new ToolStripItem[]
{
ctxSpam, ctxNotSpam,
new ToolStripSeparator(),
ctxBlockDomain, ctxAllowDomain
});
// Grid
_grid = new DataGridView _grid = new DataGridView
{ {
Dock = DockStyle.Fill, Dock = DockStyle.Fill,
@@ -26,7 +103,8 @@ public sealed class ActivityLogForm : Form
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
SelectionMode = DataGridViewSelectionMode.FullRowSelect, SelectionMode = DataGridViewSelectionMode.FullRowSelect,
BackgroundColor = System.Drawing.SystemColors.Window, BackgroundColor = System.Drawing.SystemColors.Window,
BorderStyle = BorderStyle.None BorderStyle = BorderStyle.None,
ContextMenuStrip = _rowMenu
}; };
_grid.Columns.Add("Time", "Time"); _grid.Columns.Add("Time", "Time");
@@ -35,17 +113,143 @@ public sealed class ActivityLogForm : Form
_grid.Columns.Add("Verdict", "Verdict"); _grid.Columns.Add("Verdict", "Verdict");
_grid.Columns.Add("Confidence", "Confidence"); _grid.Columns.Add("Confidence", "Confidence");
_grid.Columns.Add("Reason", "Reason"); _grid.Columns.Add("Reason", "Reason");
_grid.Columns.Add("Status", "");
_grid.Columns["Time"]!.Width = 70; _grid.Columns["Time"]!.Width = 70;
_grid.Columns["From"]!.Width = 150; _grid.Columns["From"]!.Width = 150;
_grid.Columns["Verdict"]!.Width = 80; _grid.Columns["Verdict"]!.Width = 80;
_grid.Columns["Confidence"]!.Width = 80; _grid.Columns["Confidence"]!.Width = 80;
_grid.Columns["Status"]!.Width = 30;
_grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None;
Controls.Add(_grid); Controls.Add(_grid);
Controls.Add(_toolbar);
// Subscribe to data changes — refresh only when something actually changes
_activityLog.EntryChanged += RefreshData;
RefreshData(); RefreshData();
} }
protected override bool ProcessCmdKey(ref Message msg, Keys keyData)
{
if (_grid.SelectedRows.Count == 0)
return base.ProcessCmdKey(ref msg, keyData);
switch (keyData)
{
case Keys.S:
OnMarkSpam(this, EventArgs.Empty);
return true;
case Keys.H:
OnMarkNotSpam(this, EventArgs.Empty);
return true;
case Keys.D:
OnBlockDomain(this, EventArgs.Empty);
return true;
default:
return base.ProcessCmdKey(ref msg, keyData);
}
}
private async void OnMarkSpam(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || entry.Uid == 0) return;
var monitor = FindMonitor(entry.AccountName);
if (monitor == null) return;
try
{
await monitor.MoveToSpamAsync(entry.Uid);
RecordOverride(entry, "spam");
_activityLog.UpdateEntry(entry, Verdict.Spam, UserOverride.MarkedSpam);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private async void OnMarkNotSpam(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || entry.Uid == 0) return;
var monitor = FindMonitor(entry.AccountName);
if (monitor == null) return;
try
{
await monitor.MoveToInboxAsync(entry.Uid);
_trustedSenders.Add(entry.Sender);
_trustedSenders.Save();
RecordOverride(entry, "legitimate");
_activityLog.UpdateEntry(entry, Verdict.Legitimate, UserOverride.MarkedLegitimate);
}
catch (Exception ex)
{
MessageBox.Show($"Failed to move email: {ex.Message}", "Error",
MessageBoxButtons.OK, MessageBoxIcon.Error);
}
}
private void OnBlockDomain(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
if (string.IsNullOrEmpty(domain)) return;
_blockedDomains.Add(domain);
_blockedDomains.Save();
OnMarkSpam(sender, e);
}
private void OnAllowDomain(object? sender, EventArgs e)
{
var entry = GetSelectedEntry();
if (entry == null || string.IsNullOrEmpty(entry.Sender)) return;
var domain = BlockedDomainStore.ExtractDomain(entry.Sender);
if (string.IsNullOrEmpty(domain)) return;
_blockedDomains.Remove(domain);
_blockedDomains.Save();
OnMarkNotSpam(sender, e);
}
private ActivityEntry? GetSelectedEntry()
{
if (_grid.SelectedRows.Count == 0) return null;
return _grid.SelectedRows[0].Tag as ActivityEntry;
}
private InboxMonitorService? FindMonitor(string accountName)
{
return _monitors.FirstOrDefault(m =>
m.AccountName.Equals(accountName, StringComparison.OrdinalIgnoreCase));
}
private void RecordOverride(ActivityEntry entry, string userVerdict)
{
var originalVerdict = entry.Verdict.ToString().ToLowerInvariant();
_overrideStore.Add(new OverrideRecord(
DateTime.UtcNow,
entry.Sender,
entry.Subject,
entry.BodySnippet,
userVerdict,
originalVerdict
));
_overrideStore.Save();
}
public void RefreshData() public void RefreshData()
{ {
if (InvokeRequired) if (InvokeRequired)
@@ -54,21 +258,41 @@ public sealed class ActivityLogForm : Form
return; return;
} }
var entries = _activityLog.GetRecent(200); // Preserve selection and scroll position across refresh
var selectedEntry = GetSelectedEntry();
var selectedUid = selectedEntry?.Uid ?? 0;
var selectedTimestamp = selectedEntry?.Timestamp ?? default;
var firstVisible = _grid.FirstDisplayedScrollingRowIndex;
var entries = _activityLog.GetRecent(DisplayCount);
_grid.SuspendLayout();
_grid.Rows.Clear(); _grid.Rows.Clear();
int restoreIndex = -1;
foreach (var entry in entries) foreach (var entry in entries)
{ {
var overrideMarker = entry.Override switch
{
UserOverride.MarkedSpam => "\u2716", // heavy X
UserOverride.MarkedLegitimate => "\u2714", // heavy checkmark
_ => ""
};
var rowIndex = _grid.Rows.Add( var rowIndex = _grid.Rows.Add(
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"), entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
entry.Sender, entry.Sender,
entry.Subject, entry.Subject,
entry.Verdict.ToString(), entry.Verdict.ToString(),
entry.Confidence?.ToString("P0") ?? "--", entry.Confidence?.ToString("P0") ?? "--",
entry.Reason ?? "" entry.Reason ?? "",
overrideMarker
); );
var row = _grid.Rows[rowIndex]; var row = _grid.Rows[rowIndex];
row.Tag = entry;
row.DefaultCellStyle.ForeColor = entry.Verdict switch row.DefaultCellStyle.ForeColor = entry.Verdict switch
{ {
Verdict.Spam => System.Drawing.Color.Red, Verdict.Spam => System.Drawing.Color.Red,
@@ -77,7 +301,36 @@ public sealed class ActivityLogForm : Form
Verdict.Error => System.Drawing.Color.Gray, Verdict.Error => System.Drawing.Color.Gray,
_ => System.Drawing.SystemColors.ControlText _ => System.Drawing.SystemColors.ControlText
}; };
if (entry.Override != null)
{
row.DefaultCellStyle.Font = new System.Drawing.Font(
_grid.DefaultCellStyle.Font ?? System.Drawing.SystemFonts.DefaultFont,
System.Drawing.FontStyle.Bold);
} }
if (restoreIndex < 0)
{
if (selectedUid != 0 && entry.Uid == selectedUid)
restoreIndex = rowIndex;
else if (selectedUid == 0 && entry.Timestamp == selectedTimestamp)
restoreIndex = rowIndex;
}
}
// Restore scroll position
if (_grid.RowCount > 0 && firstVisible >= 0)
{
var clampedFirst = Math.Min(firstVisible, _grid.RowCount - 1);
_grid.FirstDisplayedScrollingRowIndex = clampedFirst;
}
// Restore selection without triggering a scroll jump
_grid.ClearSelection();
if (restoreIndex >= 0)
_grid.Rows[restoreIndex].Selected = true;
_grid.ResumeLayout();
} }
protected override void OnFormClosing(FormClosingEventArgs e) protected override void OnFormClosing(FormClosingEventArgs e)
@@ -92,4 +345,11 @@ public sealed class ActivityLogForm : Form
base.OnFormClosing(e); base.OnFormClosing(e);
} }
} }
protected override void Dispose(bool disposing)
{
if (disposing)
_activityLog.EntryChanged -= RefreshData;
base.Dispose(disposing);
}
} }

View File

@@ -4,22 +4,31 @@ namespace SpamGuard.Tray;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using SpamGuard.Services; using SpamGuard.Services;
using SpamGuard.State;
public sealed class TrayApplicationContext : ApplicationContext public sealed class TrayApplicationContext : ApplicationContext
{ {
private readonly NotifyIcon _notifyIcon; private readonly NotifyIcon _notifyIcon;
private readonly System.Windows.Forms.Timer _refreshTimer; private readonly System.Windows.Forms.Timer _refreshTimer;
private readonly ActivityLog _activityLog; private readonly ActivityLog _activityLog;
private readonly InboxMonitorService _monitor; private readonly IReadOnlyList<InboxMonitorService> _monitors;
private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly OverrideStore _overrideStore;
private readonly IHost _host; private readonly IHost _host;
private ActivityLogForm? _logForm; private ActivityLogForm? _logForm;
private readonly ToolStripMenuItem _pauseMenuItem; private readonly ToolStripMenuItem _pauseMenuItem;
private bool AllPaused => _monitors.All(m => m.IsPaused);
public TrayApplicationContext(IHost host) public TrayApplicationContext(IHost host)
{ {
_host = host; _host = host;
_activityLog = host.Services.GetRequiredService<ActivityLog>(); _activityLog = host.Services.GetRequiredService<ActivityLog>();
_monitor = host.Services.GetRequiredService<InboxMonitorService>(); _monitors = host.Services.GetServices<InboxMonitorService>().ToList();
_trustedSenders = host.Services.GetRequiredService<TrustedSenderStore>();
_blockedDomains = host.Services.GetRequiredService<BlockedDomainStore>();
_overrideStore = host.Services.GetRequiredService<OverrideStore>();
_pauseMenuItem = new ToolStripMenuItem("Pause", null, OnPauseResume); _pauseMenuItem = new ToolStripMenuItem("Pause", null, OnPauseResume);
@@ -39,6 +48,7 @@ public sealed class TrayApplicationContext : ApplicationContext
_notifyIcon.DoubleClick += OnViewLog; _notifyIcon.DoubleClick += OnViewLog;
// Timer only updates tray tooltip and icon — form refresh is event-driven
_refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 }; _refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 };
_refreshTimer.Tick += OnRefreshTick; _refreshTimer.Tick += OnRefreshTick;
_refreshTimer.Start(); _refreshTimer.Start();
@@ -51,8 +61,7 @@ public sealed class TrayApplicationContext : ApplicationContext
var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today"; var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip; _notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
// Update icon based on state if (!AllPaused)
if (!_monitor.IsPaused)
{ {
var recent = _activityLog.GetRecent(1); var recent = _activityLog.GetRecent(1);
var hasRecentError = recent.Count > 0 var hasRecentError = recent.Count > 0
@@ -61,14 +70,15 @@ public sealed class TrayApplicationContext : ApplicationContext
_notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green; _notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green;
} }
_logForm?.RefreshData(); // Form refresh removed — ActivityLogForm subscribes to ActivityLog.EntryChanged directly
} }
private void OnViewLog(object? sender, EventArgs e) private void OnViewLog(object? sender, EventArgs e)
{ {
if (_logForm == null || _logForm.IsDisposed) if (_logForm == null || _logForm.IsDisposed)
{ {
_logForm = new ActivityLogForm(_activityLog); _logForm = new ActivityLogForm(
_activityLog, _monitors, _trustedSenders, _blockedDomains, _overrideStore);
} }
_logForm.Show(); _logForm.Show();
_logForm.BringToFront(); _logForm.BringToFront();
@@ -76,15 +86,15 @@ public sealed class TrayApplicationContext : ApplicationContext
private void OnPauseResume(object? sender, EventArgs e) private void OnPauseResume(object? sender, EventArgs e)
{ {
if (_monitor.IsPaused) if (AllPaused)
{ {
_monitor.Resume(); foreach (var m in _monitors) m.Resume();
_pauseMenuItem.Text = "Pause"; _pauseMenuItem.Text = "Pause";
_notifyIcon.Icon = IconGenerator.Green; _notifyIcon.Icon = IconGenerator.Green;
} }
else else
{ {
_monitor.Pause(); foreach (var m in _monitors) m.Pause();
_pauseMenuItem.Text = "Resume"; _pauseMenuItem.Text = "Resume";
_notifyIcon.Icon = IconGenerator.Yellow; _notifyIcon.Icon = IconGenerator.Yellow;
} }

View File

@@ -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": {

View File

@@ -8,6 +8,7 @@ using Microsoft.Extensions.Options;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.Models; using SpamGuard.Models;
using SpamGuard.Services; using SpamGuard.Services;
using SpamGuard.State;
public class EmailClassifierTests public class EmailClassifierTests
{ {
@@ -35,7 +36,8 @@ public class EmailClassifierTests
var classifier = new EmailClassifier( var classifier = new EmailClassifier(
Options.Create(DefaultOptions), Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(), new NullLogger<EmailClassifier>(),
new HttpClient() new HttpClient(),
new OverrideStore(Path.GetTempPath())
); );
var prompt = classifier.BuildPrompt(SampleEmail); var prompt = classifier.BuildPrompt(SampleEmail);
@@ -54,7 +56,8 @@ public class EmailClassifierTests
var classifier = new EmailClassifier( var classifier = new EmailClassifier(
Options.Create(DefaultOptions), Options.Create(DefaultOptions),
new NullLogger<EmailClassifier>(), new NullLogger<EmailClassifier>(),
new HttpClient() new HttpClient(),
new OverrideStore(Path.GetTempPath())
); );
var prompt = classifier.BuildPrompt(email); var prompt = classifier.BuildPrompt(email);

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