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 ImapOptions Imap { get; set; } = new();
public List<ImapOptions> Accounts { get; set; } = new();
public ClaudeOptions Claude { get; set; } = new();
public MonitoringOptions Monitoring { get; set; } = new();
}
public sealed class ImapOptions
{
public string Name { get; set; } = "";
public string Host { get; set; } = "";
public int Port { get; set; } = 993;
public bool UseSsl { get; set; } = true;
@@ -21,6 +22,7 @@ public sealed class ImapOptions
public sealed class ClaudeOptions
{
public string ApiKey { get; set; } = "";
public string BaseUrl { get; set; } = "https://api.anthropic.com/";
public string Model { get; set; } = "claude-sonnet-4-6";
public int MaxBodyLength { get; set; } = 2000;
}

View File

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

View File

@@ -10,11 +10,22 @@ public enum Verdict
Error
}
public sealed record ActivityEntry(
DateTime Timestamp,
string Sender,
string Subject,
Verdict Verdict,
double? Confidence,
string? Reason
);
public enum UserOverride
{
MarkedSpam,
MarkedLegitimate
}
public sealed class ActivityEntry
{
public DateTime Timestamp { get; init; }
public string Sender { get; init; } = "";
public string Subject { get; init; } = "";
public Verdict Verdict { get; set; }
public double? Confidence { get; init; }
public string? Reason { get; init; }
public uint Uid { get; init; }
public string AccountName { get; init; } = "";
public string? BodySnippet { get; init; }
public UserOverride? Override { get; set; }
}

View File

@@ -8,6 +8,8 @@ using SpamGuard.Services;
using SpamGuard.State;
using SpamGuard.Tray;
#pragma warning disable CA1416 // Validate platform compatibility
namespace SpamGuard;
static class Program
@@ -52,32 +54,69 @@ static class Program
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
// State stores
services.AddSingleton(new ProcessedUidStore(dataDir));
services.AddSingleton(new TrustedSenderStore(dataDir));
var opts = context.Configuration
.GetSection(SpamGuardOptions.SectionName)
.Get<SpamGuardOptions>()!;
// Services
services.AddSingleton(sp =>
{
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
});
// Shared state / services
services.AddSingleton(new TrustedSenderStore(dataDir));
services.AddSingleton(new BlockedDomainStore(dataDir));
services.AddSingleton(new OverrideStore(dataDir));
services.AddSingleton(new ActivityLog(opts.Monitoring.MaxActivityLogEntries, dataDir));
services.AddSingleton<ImapClientFactory>();
// EmailClassifier with managed HttpClient (timeout + base address)
// EmailClassifier with managed HttpClient
services.AddHttpClient<EmailClassifier>(client =>
{
client.Timeout = TimeSpan.FromSeconds(30);
client.BaseAddress = new Uri("https://api.anthropic.com/");
client.BaseAddress = new Uri(opts.Claude.BaseUrl);
});
// Background services
services.AddSingleton<InboxMonitorService>();
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>());
services.AddHostedService<TrustedSenderService>();
// Per-account monitor + trusted sender service
var monitors = new List<InboxMonitorService>();
foreach (var account in opts.Accounts)
{
var accountName = account.Name.Length > 0 ? account.Name : account.Username;
var uidStore = new ProcessedUidStore(dataDir, accountName);
services.AddHostedService(sp => new TrustedSenderService(
sp.GetRequiredService<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();
// Trigger DI resolution of InboxMonitorService singletons
host.Services.GetServices<InboxMonitorService>();
host.Start();
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;
using System.Text.Json;
using SpamGuard.Models;
public sealed class ActivityLog
@@ -7,13 +8,21 @@ public sealed class ActivityLog
private readonly List<ActivityEntry> _entries = new();
private readonly object _lock = new();
private readonly int _maxEntries;
private readonly string? _filePath;
public event Action? EntryChanged;
public int TodayChecked => GetTodayCount(_ => true);
public int TodaySpam => GetTodayCount(e => e.Verdict == Verdict.Spam);
public ActivityLog(int maxEntries = 500)
public ActivityLog(int maxEntries = 500, string? dataDirectory = null)
{
_maxEntries = maxEntries;
if (dataDirectory != null)
{
_filePath = Path.Combine(dataDirectory, "activity-log.json");
LoadFromDisk();
}
}
public void Add(ActivityEntry entry)
@@ -24,6 +33,8 @@ public sealed class ActivityLog
if (_entries.Count > _maxEntries)
_entries.RemoveAt(0);
}
SaveToDisk();
EntryChanged?.Invoke();
}
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)
{
var today = DateTime.UtcNow.Date;
@@ -45,4 +79,39 @@ public sealed class ActivityLog
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 SpamGuard.Configuration;
using SpamGuard.Models;
using SpamGuard.State;
public sealed partial class EmailClassifier
{
private readonly SpamGuardOptions _options;
private readonly ILogger<EmailClassifier> _logger;
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.
Spam includes:
- Unsolicited marketing or promotional emails the recipient never signed up for
- 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:
- Emails from known contacts or businesses the recipient has a relationship with
- Transactional emails (receipts, shipping notifications, password resets)
- 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"}
""";
public EmailClassifier(
IOptions<SpamGuardOptions> options,
ILogger<EmailClassifier> logger,
HttpClient httpClient)
HttpClient httpClient,
OverrideStore overrideStore)
{
_options = options.Value;
_logger = logger;
_httpClient = httpClient;
_overrideStore = overrideStore;
}
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);
var systemPrompt = BaseSystemPrompt + _overrideStore.BuildFewShotText(10);
var requestBody = new
{
model = _options.Claude.Model,
max_tokens = 256,
system = SystemPrompt,
system = systemPrompt,
messages = new[]
{
new { role = "user", content = userMessage }
@@ -78,11 +85,27 @@ public sealed partial class EmailClassifier
{
Content = new StringContent(json, Encoding.UTF8, "application/json")
};
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
request.Headers.Add("anthropic-version", "2023-06-01");
var isAnthropic = _options.Claude.BaseUrl.Contains("anthropic.com", StringComparison.OrdinalIgnoreCase);
if (isAnthropic)
{
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
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);
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);
using var doc = JsonDocument.Parse(responseJson);
@@ -108,18 +131,32 @@ public sealed partial class EmailClassifier
// Strip markdown code fencing if present
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
{
using var doc = JsonDocument.Parse(cleaned);
using var doc = JsonDocument.Parse(json);
var root = doc.RootElement;
return new ClassificationResult(
Classification: root.GetProperty("classification").GetString() ?? "unknown",
Confidence: root.GetProperty("confidence").GetDouble(),
Reason: root.GetProperty("reason").GetString() ?? ""
);
}
catch (Exception)
catch
{
return null;
}

View File

@@ -3,24 +3,20 @@ namespace SpamGuard.Services;
using MailKit.Net.Imap;
using MailKit.Security;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using SpamGuard.Configuration;
public sealed class ImapClientFactory
{
private readonly SpamGuardOptions _options;
private readonly ILogger<ImapClientFactory> _logger;
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger)
public ImapClientFactory(ILogger<ImapClientFactory> logger)
{
_options = options.Value;
_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 imap = _options.Imap;
_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 Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit;
using SpamGuard.Configuration;
using SpamGuard.Models;
@@ -15,15 +14,18 @@ public sealed partial class InboxMonitorService : BackgroundService
{
private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly ProcessedUidStore _processedUids;
private readonly EmailClassifier _classifier;
private readonly ActivityLog _activityLog;
private readonly SpamGuardOptions _options;
private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused;
private uint _lastSeenUid;
public string AccountName => _imap.Name.Length > 0 ? _imap.Name : _imap.Username;
public bool IsPaused => _paused;
public void Pause() => _paused = true;
public void Resume() => _paused = false;
@@ -31,24 +33,28 @@ public sealed partial class InboxMonitorService : BackgroundService
public InboxMonitorService(
ImapClientFactory imapFactory,
TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
ProcessedUidStore processedUids,
EmailClassifier classifier,
ActivityLog activityLog,
IOptions<SpamGuardOptions> options,
ImapOptions imap,
MonitoringOptions monitoring,
ILogger<InboxMonitorService> logger)
{
_imapFactory = imapFactory;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_processedUids = processedUids;
_classifier = classifier;
_activityLog = activityLog;
_options = options.Value;
_imap = imap;
_monitoring = monitoring;
_logger = logger;
}
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
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
@@ -70,69 +76,98 @@ public sealed partial class InboxMonitorService : BackgroundService
catch (Exception ex)
{
_logger.LogError(ex, "Error polling inbox");
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message));
_activityLog.Add(new ActivityEntry
{
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)
{
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
try
{
var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
// Build search query: only fetch new messages
IList<UniqueId> uids;
if (_lastSeenUid > 0)
{
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
uids = await inbox.SearchAsync(range, SearchQuery.All, ct);
}
else if (_processedUids.Count > 0)
{
// Resuming from persisted state -- scan recent messages only
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-1)), ct);
}
else
{
// First ever run -- initial scan window
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct);
}
_logger.LogDebug("Found {Count} messages in inbox", uids.Count);
// Find the spam/junk folder
var spamFolder = await FindSpamFolderAsync(client, ct);
foreach (var uid in uids)
{
if (_processedUids.Contains(uid.Id))
// Build search query: only fetch new messages
IList<UniqueId> uids;
if (_lastSeenUid > 0)
{
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
uids = await inbox.SearchAsync(range, SearchQuery.All, ct);
}
else if (_processedUids.Count > 0)
{
// Resuming from persisted state -- scan recent messages only
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-1)), ct);
}
else
{
// First ever run -- initial scan window
uids = await inbox.SearchAsync(
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_monitoring.InitialScanDays)), ct);
}
_logger.LogDebug("Found {Count} messages in inbox", uids.Count);
// Find the spam/junk folder
var spamFolder = await FindSpamFolderAsync(client, ct);
foreach (var uid in uids)
{
if (_processedUids.Contains(uid.Id))
{
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
continue;
}
try
{
await ProcessMessageAsync(inbox, uid, spamFolder, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = "", Subject = $"UID {uid.Id}",
Verdict = Verdict.Error, Reason = ex.Message,
Uid = uid.Id, AccountName = AccountName
});
_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;
continue;
}
try
{
await ProcessMessageAsync(inbox, uid, spamFolder, ct);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message));
_processedUids.Add(uid.Id);
}
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
await client.DisconnectAsync(true, ct);
}
finally
{
client.Dispose();
}
await client.DisconnectAsync(true, ct);
}
private async Task ProcessMessageAsync(
@@ -141,19 +176,41 @@ public sealed partial class InboxMonitorService : BackgroundService
var message = await inbox.GetMessageAsync(uid, ct);
var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown";
var subject = message.Subject ?? "(no subject)";
var bodySnippet = ExtractBodySnippet(message);
// Check trusted senders
if (_trustedSenders.IsTrusted(from))
{
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null));
_activityLog.Add(new ActivityEntry
{
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);
return;
}
// Extract body snippet
var bodySnippet = ExtractBodySnippet(message);
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
// Classify
@@ -161,15 +218,18 @@ public sealed partial class InboxMonitorService : BackgroundService
if (result == null)
{
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed"));
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Error, Reason = "Classification failed",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id);
return;
}
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold)
if (result.IsSpam && result.Confidence >= _monitoring.SpamConfidenceThreshold)
{
// Move to spam folder
if (spamFolder != null)
{
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);
}
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason));
_activityLog.Add(new ActivityEntry
{
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)
{
// Below threshold -- uncertain
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason));
_activityLog.Add(new ActivityEntry
{
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Uncertain, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
}
else
{
_activityLog.Add(new ActivityEntry(
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason));
_activityLog.Add(new ActivityEntry
{
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);
@@ -225,32 +296,108 @@ public sealed partial class InboxMonitorService : BackgroundService
[System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")]
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)
{
// Try special folder first
// 1. Try IMAP special-use \Junk attribute (most reliable when server advertises it)
try
{
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)
{
_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
try
// 2. Search by name — try configured name plus common variants, two levels deep
var candidates = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
{
var personal = client.GetFolder(client.PersonalNamespaces[0]);
var folders = await personal.GetSubfoldersAsync(false, ct);
return folders.FirstOrDefault(f =>
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase));
}
catch (Exception ex)
_monitoring.SpamFolderName, "Junk", "Spam", "Junk Email", "Junk E-mail", "Junk Mail", "Bulk Mail"
};
foreach (var ns in client.PersonalNamespaces)
{
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName);
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
{
var sub = await folder.GetSubfoldersAsync(false, ct);
if (sub.Count > 0)
_logger.LogDebug("Subfolders of {Parent}: {Subs}",
folder.FullName, string.Join(", ", sub.Select(f => f.FullName)));
var match = sub.FirstOrDefault(f => candidates.Contains(f.Name));
if (match != null)
{
_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;
}
}

View File

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

View File

@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
@@ -20,4 +20,10 @@
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</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 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();
}

View File

@@ -2,21 +2,98 @@ namespace SpamGuard.Tray;
using SpamGuard.Models;
using SpamGuard.Services;
using SpamGuard.State;
public sealed class ActivityLogForm : Form
{
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 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;
_monitors = monitors;
_trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_overrideStore = overrideStore;
Text = "SpamGuard - Activity Log";
Size = new System.Drawing.Size(800, 500);
Size = new System.Drawing.Size(900, 550);
StartPosition = FormStartPosition.CenterScreen;
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
{
Dock = DockStyle.Fill,
@@ -26,7 +103,8 @@ public sealed class ActivityLogForm : Form
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
BackgroundColor = System.Drawing.SystemColors.Window,
BorderStyle = BorderStyle.None
BorderStyle = BorderStyle.None,
ContextMenuStrip = _rowMenu
};
_grid.Columns.Add("Time", "Time");
@@ -35,17 +113,143 @@ public sealed class ActivityLogForm : Form
_grid.Columns.Add("Verdict", "Verdict");
_grid.Columns.Add("Confidence", "Confidence");
_grid.Columns.Add("Reason", "Reason");
_grid.Columns.Add("Status", "");
_grid.Columns["Time"]!.Width = 70;
_grid.Columns["From"]!.Width = 150;
_grid.Columns["Verdict"]!.Width = 80;
_grid.Columns["Confidence"]!.Width = 80;
_grid.Columns["Status"]!.Width = 30;
_grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None;
Controls.Add(_grid);
Controls.Add(_toolbar);
// Subscribe to data changes — refresh only when something actually changes
_activityLog.EntryChanged += 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()
{
if (InvokeRequired)
@@ -54,21 +258,41 @@ public sealed class ActivityLogForm : Form
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();
int restoreIndex = -1;
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(
entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"),
entry.Sender,
entry.Subject,
entry.Verdict.ToString(),
entry.Confidence?.ToString("P0") ?? "--",
entry.Reason ?? ""
entry.Reason ?? "",
overrideMarker
);
var row = _grid.Rows[rowIndex];
row.Tag = entry;
row.DefaultCellStyle.ForeColor = entry.Verdict switch
{
Verdict.Spam => System.Drawing.Color.Red,
@@ -77,7 +301,36 @@ public sealed class ActivityLogForm : Form
Verdict.Error => System.Drawing.Color.Gray,
_ => 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)
@@ -92,4 +345,11 @@ public sealed class ActivityLogForm : Form
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.Hosting;
using SpamGuard.Services;
using SpamGuard.State;
public sealed class TrayApplicationContext : ApplicationContext
{
private readonly NotifyIcon _notifyIcon;
private readonly System.Windows.Forms.Timer _refreshTimer;
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 ActivityLogForm? _logForm;
private readonly ToolStripMenuItem _pauseMenuItem;
private bool AllPaused => _monitors.All(m => m.IsPaused);
public TrayApplicationContext(IHost host)
{
_host = host;
_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);
@@ -39,6 +48,7 @@ public sealed class TrayApplicationContext : ApplicationContext
_notifyIcon.DoubleClick += OnViewLog;
// Timer only updates tray tooltip and icon — form refresh is event-driven
_refreshTimer = new System.Windows.Forms.Timer { Interval = 5000 };
_refreshTimer.Tick += OnRefreshTick;
_refreshTimer.Start();
@@ -51,8 +61,7 @@ public sealed class TrayApplicationContext : ApplicationContext
var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
// Update icon based on state
if (!_monitor.IsPaused)
if (!AllPaused)
{
var recent = _activityLog.GetRecent(1);
var hasRecentError = recent.Count > 0
@@ -61,14 +70,15 @@ public sealed class TrayApplicationContext : ApplicationContext
_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)
{
if (_logForm == null || _logForm.IsDisposed)
{
_logForm = new ActivityLogForm(_activityLog);
_logForm = new ActivityLogForm(
_activityLog, _monitors, _trustedSenders, _blockedDomains, _overrideStore);
}
_logForm.Show();
_logForm.BringToFront();
@@ -76,15 +86,15 @@ public sealed class TrayApplicationContext : ApplicationContext
private void OnPauseResume(object? sender, EventArgs e)
{
if (_monitor.IsPaused)
if (AllPaused)
{
_monitor.Resume();
foreach (var m in _monitors) m.Resume();
_pauseMenuItem.Text = "Pause";
_notifyIcon.Icon = IconGenerator.Green;
}
else
{
_monitor.Pause();
foreach (var m in _monitors) m.Pause();
_pauseMenuItem.Text = "Resume";
_notifyIcon.Icon = IconGenerator.Yellow;
}

View File

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

View File

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

View File

@@ -12,7 +12,7 @@ public class ProcessedUidStoreTests : IDisposable
{
_tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}");
Directory.CreateDirectory(_tempDir);
_store = new ProcessedUidStore(_tempDir);
_store = new ProcessedUidStore(_tempDir, "TestAccount");
}
public void Dispose()
@@ -50,7 +50,7 @@ public class ProcessedUidStoreTests : IDisposable
_store.Add(3);
_store.Save();
var loaded = new ProcessedUidStore(_tempDir);
var loaded = new ProcessedUidStore(_tempDir, "TestAccount");
Assert.True(loaded.Contains(1));
Assert.True(loaded.Contains(2));
Assert.True(loaded.Contains(3));