Compare commits
6 Commits
66cca61b21
...
387c0dc155
| Author | SHA1 | Date | |
|---|---|---|---|
| 387c0dc155 | |||
| 8aa35469fc | |||
| 5c801cef4b | |||
| b5f8b7300b | |||
| 27c6d12183 | |||
| 4d8342b658 |
@@ -4,13 +4,14 @@ public sealed class SpamGuardOptions
|
|||||||
{
|
{
|
||||||
public const string SectionName = "SpamGuard";
|
public const string SectionName = "SpamGuard";
|
||||||
|
|
||||||
public ImapOptions Imap { get; set; } = new();
|
public List<ImapOptions> Accounts { get; set; } = new();
|
||||||
public ClaudeOptions Claude { get; set; } = new();
|
public ClaudeOptions Claude { get; set; } = new();
|
||||||
public MonitoringOptions Monitoring { get; set; } = new();
|
public MonitoringOptions Monitoring { get; set; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class ImapOptions
|
public sealed class ImapOptions
|
||||||
{
|
{
|
||||||
|
public string Name { get; set; } = "";
|
||||||
public string Host { get; set; } = "";
|
public string Host { get; set; } = "";
|
||||||
public int Port { get; set; } = 993;
|
public int Port { get; set; } = 993;
|
||||||
public bool UseSsl { get; set; } = true;
|
public bool UseSsl { get; set; } = true;
|
||||||
@@ -21,6 +22,7 @@ public sealed class ImapOptions
|
|||||||
public sealed class ClaudeOptions
|
public sealed class ClaudeOptions
|
||||||
{
|
{
|
||||||
public string ApiKey { get; set; } = "";
|
public string ApiKey { get; set; } = "";
|
||||||
|
public string BaseUrl { get; set; } = "https://api.anthropic.com/";
|
||||||
public string Model { get; set; } = "claude-sonnet-4-6";
|
public string Model { get; set; } = "claude-sonnet-4-6";
|
||||||
public int MaxBodyLength { get; set; } = 2000;
|
public int MaxBodyLength { get; set; } = 2000;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,12 +8,21 @@ public sealed class SpamGuardOptionsValidator : IValidateOptions<SpamGuardOption
|
|||||||
{
|
{
|
||||||
var errors = new List<string>();
|
var errors = new List<string>();
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(options.Imap.Host))
|
if (options.Accounts.Count == 0)
|
||||||
errors.Add("SpamGuard:Imap:Host is required.");
|
errors.Add("SpamGuard:Accounts must contain at least one account.");
|
||||||
if (string.IsNullOrWhiteSpace(options.Imap.Username))
|
|
||||||
errors.Add("SpamGuard:Imap:Username is required.");
|
for (int i = 0; i < options.Accounts.Count; i++)
|
||||||
if (string.IsNullOrWhiteSpace(options.Imap.Password))
|
{
|
||||||
errors.Add("SpamGuard:Imap:Password is required.");
|
var a = options.Accounts[i];
|
||||||
|
var prefix = $"SpamGuard:Accounts[{i}]";
|
||||||
|
if (string.IsNullOrWhiteSpace(a.Host))
|
||||||
|
errors.Add($"{prefix}:Host is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(a.Username))
|
||||||
|
errors.Add($"{prefix}:Username is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(a.Password))
|
||||||
|
errors.Add($"{prefix}:Password is required.");
|
||||||
|
}
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
|
if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
|
||||||
errors.Add("SpamGuard:Claude:ApiKey is required.");
|
errors.Add("SpamGuard:Claude:ApiKey is required.");
|
||||||
|
|
||||||
|
|||||||
@@ -10,11 +10,22 @@ public enum Verdict
|
|||||||
Error
|
Error
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed record ActivityEntry(
|
public enum UserOverride
|
||||||
DateTime Timestamp,
|
{
|
||||||
string Sender,
|
MarkedSpam,
|
||||||
string Subject,
|
MarkedLegitimate
|
||||||
Verdict Verdict,
|
}
|
||||||
double? Confidence,
|
|
||||||
string? Reason
|
public sealed class ActivityEntry
|
||||||
);
|
{
|
||||||
|
public DateTime Timestamp { get; init; }
|
||||||
|
public string Sender { get; init; } = "";
|
||||||
|
public string Subject { get; init; } = "";
|
||||||
|
public Verdict Verdict { get; set; }
|
||||||
|
public double? Confidence { get; init; }
|
||||||
|
public string? Reason { get; init; }
|
||||||
|
public uint Uid { get; init; }
|
||||||
|
public string AccountName { get; init; } = "";
|
||||||
|
public string? BodySnippet { get; init; }
|
||||||
|
public UserOverride? Override { get; set; }
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ using SpamGuard.Services;
|
|||||||
using SpamGuard.State;
|
using SpamGuard.State;
|
||||||
using SpamGuard.Tray;
|
using SpamGuard.Tray;
|
||||||
|
|
||||||
|
#pragma warning disable CA1416 // Validate platform compatibility
|
||||||
|
|
||||||
namespace SpamGuard;
|
namespace SpamGuard;
|
||||||
|
|
||||||
static class Program
|
static class Program
|
||||||
@@ -52,32 +54,69 @@ static class Program
|
|||||||
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
|
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
|
||||||
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
|
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
|
||||||
|
|
||||||
// State stores
|
var opts = context.Configuration
|
||||||
services.AddSingleton(new ProcessedUidStore(dataDir));
|
.GetSection(SpamGuardOptions.SectionName)
|
||||||
services.AddSingleton(new TrustedSenderStore(dataDir));
|
.Get<SpamGuardOptions>()!;
|
||||||
|
|
||||||
// Services
|
// Shared state / services
|
||||||
services.AddSingleton(sp =>
|
services.AddSingleton(new TrustedSenderStore(dataDir));
|
||||||
{
|
services.AddSingleton(new BlockedDomainStore(dataDir));
|
||||||
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
|
services.AddSingleton(new OverrideStore(dataDir));
|
||||||
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
|
services.AddSingleton(new ActivityLog(opts.Monitoring.MaxActivityLogEntries, dataDir));
|
||||||
});
|
|
||||||
services.AddSingleton<ImapClientFactory>();
|
services.AddSingleton<ImapClientFactory>();
|
||||||
|
|
||||||
// EmailClassifier with managed HttpClient (timeout + base address)
|
// EmailClassifier with managed HttpClient
|
||||||
services.AddHttpClient<EmailClassifier>(client =>
|
services.AddHttpClient<EmailClassifier>(client =>
|
||||||
{
|
{
|
||||||
client.Timeout = TimeSpan.FromSeconds(30);
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
client.BaseAddress = new Uri("https://api.anthropic.com/");
|
client.BaseAddress = new Uri(opts.Claude.BaseUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Background services
|
// Per-account monitor + trusted sender service
|
||||||
services.AddSingleton<InboxMonitorService>();
|
var monitors = new List<InboxMonitorService>();
|
||||||
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>());
|
foreach (var account in opts.Accounts)
|
||||||
services.AddHostedService<TrustedSenderService>();
|
{
|
||||||
|
var accountName = account.Name.Length > 0 ? account.Name : account.Username;
|
||||||
|
var uidStore = new ProcessedUidStore(dataDir, accountName);
|
||||||
|
|
||||||
|
services.AddHostedService(sp => new TrustedSenderService(
|
||||||
|
sp.GetRequiredService<ImapClientFactory>(),
|
||||||
|
sp.GetRequiredService<TrustedSenderStore>(),
|
||||||
|
account,
|
||||||
|
opts.Monitoring,
|
||||||
|
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<TrustedSenderService>>()));
|
||||||
|
|
||||||
|
var capturedAccount = account;
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var monitor = new InboxMonitorService(
|
||||||
|
sp.GetRequiredService<ImapClientFactory>(),
|
||||||
|
sp.GetRequiredService<TrustedSenderStore>(),
|
||||||
|
sp.GetRequiredService<BlockedDomainStore>(),
|
||||||
|
uidStore,
|
||||||
|
sp.GetRequiredService<EmailClassifier>(),
|
||||||
|
sp.GetRequiredService<ActivityLog>(),
|
||||||
|
capturedAccount,
|
||||||
|
opts.Monitoring,
|
||||||
|
sp.GetRequiredService<Microsoft.Extensions.Logging.ILogger<InboxMonitorService>>());
|
||||||
|
monitors.Add(monitor);
|
||||||
|
return monitor;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Force singleton creation so monitors list is populated before host starts
|
||||||
|
services.AddHostedService(sp =>
|
||||||
|
{
|
||||||
|
// Resolve all InboxMonitorService singletons
|
||||||
|
sp.GetServices<InboxMonitorService>();
|
||||||
|
return new MonitorHostedServiceRunner(monitors);
|
||||||
|
});
|
||||||
})
|
})
|
||||||
.Build();
|
.Build();
|
||||||
|
|
||||||
|
// Trigger DI resolution of InboxMonitorService singletons
|
||||||
|
host.Services.GetServices<InboxMonitorService>();
|
||||||
|
|
||||||
host.Start();
|
host.Start();
|
||||||
|
|
||||||
Application.Run(new TrayApplicationContext(host));
|
Application.Run(new TrayApplicationContext(host));
|
||||||
@@ -94,3 +133,18 @@ static class Program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Bridges per-account InboxMonitorService instances into the hosted service pipeline.</summary>
|
||||||
|
sealed class MonitorHostedServiceRunner : IHostedService
|
||||||
|
{
|
||||||
|
private readonly List<InboxMonitorService> _monitors;
|
||||||
|
|
||||||
|
public MonitorHostedServiceRunner(List<InboxMonitorService> monitors)
|
||||||
|
=> _monitors = monitors;
|
||||||
|
|
||||||
|
public Task StartAsync(CancellationToken ct)
|
||||||
|
=> Task.WhenAll(_monitors.Select(m => m.StartAsync(ct)));
|
||||||
|
|
||||||
|
public Task StopAsync(CancellationToken ct)
|
||||||
|
=> Task.WhenAll(_monitors.Select(m => m.StopAsync(ct)));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,24 +3,20 @@ namespace SpamGuard.Services;
|
|||||||
using MailKit.Net.Imap;
|
using MailKit.Net.Imap;
|
||||||
using MailKit.Security;
|
using MailKit.Security;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using SpamGuard.Configuration;
|
using SpamGuard.Configuration;
|
||||||
|
|
||||||
public sealed class ImapClientFactory
|
public sealed class ImapClientFactory
|
||||||
{
|
{
|
||||||
private readonly SpamGuardOptions _options;
|
|
||||||
private readonly ILogger<ImapClientFactory> _logger;
|
private readonly ILogger<ImapClientFactory> _logger;
|
||||||
|
|
||||||
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger)
|
public ImapClientFactory(ILogger<ImapClientFactory> logger)
|
||||||
{
|
{
|
||||||
_options = options.Value;
|
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ImapClient> CreateConnectedClientAsync(CancellationToken ct = default)
|
public async Task<ImapClient> CreateConnectedClientAsync(ImapOptions imap, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
var client = new ImapClient();
|
var client = new ImapClient();
|
||||||
var imap = _options.Imap;
|
|
||||||
|
|
||||||
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
|
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using MailKit;
|
|||||||
using MailKit.Search;
|
using MailKit.Search;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
using SpamGuard.Configuration;
|
using SpamGuard.Configuration;
|
||||||
using SpamGuard.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,21 @@ 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);
|
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
|
||||||
var inbox = client.Inbox;
|
var inbox = client.Inbox;
|
||||||
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
|
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
|
||||||
|
|
||||||
@@ -102,7 +111,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);
|
||||||
|
|
||||||
@@ -124,8 +133,12 @@ public sealed partial class InboxMonitorService : BackgroundService
|
|||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,19 +154,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 +196,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 +219,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 +274,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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using MailKit;
|
|||||||
using MailKit.Search;
|
using MailKit.Search;
|
||||||
using Microsoft.Extensions.Hosting;
|
using Microsoft.Extensions.Hosting;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
|
||||||
using MimeKit;
|
using MimeKit;
|
||||||
using SpamGuard.Configuration;
|
using SpamGuard.Configuration;
|
||||||
using SpamGuard.State;
|
using SpamGuard.State;
|
||||||
@@ -14,25 +13,28 @@ public sealed class TrustedSenderService : BackgroundService
|
|||||||
{
|
{
|
||||||
private readonly ImapClientFactory _imapFactory;
|
private readonly ImapClientFactory _imapFactory;
|
||||||
private readonly TrustedSenderStore _store;
|
private readonly TrustedSenderStore _store;
|
||||||
private readonly SpamGuardOptions _options;
|
private readonly ImapOptions _imap;
|
||||||
|
private readonly MonitoringOptions _monitoring;
|
||||||
private readonly ILogger<TrustedSenderService> _logger;
|
private readonly ILogger<TrustedSenderService> _logger;
|
||||||
private bool _initialScanDone;
|
private bool _initialScanDone;
|
||||||
|
|
||||||
public TrustedSenderService(
|
public TrustedSenderService(
|
||||||
ImapClientFactory imapFactory,
|
ImapClientFactory imapFactory,
|
||||||
TrustedSenderStore store,
|
TrustedSenderStore store,
|
||||||
IOptions<SpamGuardOptions> options,
|
ImapOptions imap,
|
||||||
|
MonitoringOptions monitoring,
|
||||||
ILogger<TrustedSenderService> logger)
|
ILogger<TrustedSenderService> logger)
|
||||||
{
|
{
|
||||||
_imapFactory = imapFactory;
|
_imapFactory = imapFactory;
|
||||||
_store = store;
|
_store = store;
|
||||||
_options = options.Value;
|
_imap = imap;
|
||||||
|
_monitoring = monitoring;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("TrustedSenderService started");
|
_logger.LogInformation("TrustedSenderService started for {Account}", _imap.Username);
|
||||||
|
|
||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
@@ -51,22 +53,22 @@ public sealed class TrustedSenderService : BackgroundService
|
|||||||
_logger.LogError(ex, "Error scanning sent folder");
|
_logger.LogError(ex, "Error scanning sent folder");
|
||||||
}
|
}
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromMinutes(_options.Monitoring.TrustedSenderRefreshMinutes), stoppingToken);
|
await Task.Delay(TimeSpan.FromMinutes(_monitoring.TrustedSenderRefreshMinutes), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task ScanSentFolderAsync(CancellationToken ct)
|
private async Task ScanSentFolderAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
|
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
|
||||||
|
|
||||||
var sentFolder = client.GetFolder(MailKit.SpecialFolder.Sent)
|
var sentFolder = await FindSentFolderAsync(client, ct)
|
||||||
?? throw new InvalidOperationException("Could not find Sent folder");
|
?? throw new InvalidOperationException("Could not find Sent folder");
|
||||||
|
|
||||||
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
|
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||||
|
|
||||||
// After initial full scan, only check messages from the last refresh period
|
// After initial full scan, only check messages from the last refresh period
|
||||||
var query = _initialScanDone
|
var query = _initialScanDone
|
||||||
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_options.Monitoring.TrustedSenderRefreshMinutes))
|
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_monitoring.TrustedSenderRefreshMinutes))
|
||||||
: SearchQuery.All;
|
: SearchQuery.All;
|
||||||
|
|
||||||
var uids = await sentFolder.SearchAsync(query, ct);
|
var uids = await sentFolder.SearchAsync(query, ct);
|
||||||
@@ -84,6 +86,37 @@ public sealed class TrustedSenderService : BackgroundService
|
|||||||
await client.DisconnectAsync(true, ct);
|
await client.DisconnectAsync(true, ct);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static readonly string[] SentFolderNames = ["Sent", "Sent Items", "Sent Messages", "INBOX.Sent"];
|
||||||
|
|
||||||
|
private async Task<IMailFolder?> FindSentFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Try special folder first (requires SPECIAL-USE or XLIST extension)
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var sent = client.GetFolder(MailKit.SpecialFolder.Sent);
|
||||||
|
if (sent != null) return sent;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "SPECIAL-USE not supported, falling back to folder name lookup");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to well-known folder names
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var personal = client.GetFolder(client.PersonalNamespaces[0]);
|
||||||
|
var folders = await personal.GetSubfoldersAsync(false, ct);
|
||||||
|
return folders.FirstOrDefault(f =>
|
||||||
|
SentFolderNames.Any(name => f.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogWarning(ex, "Could not enumerate folders to find Sent folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
private static void ExtractAddresses(HeaderList headers, List<string> addresses)
|
private static void ExtractAddresses(HeaderList headers, List<string> addresses)
|
||||||
{
|
{
|
||||||
foreach (var headerName in new[] { "To", "Cc" })
|
foreach (var headerName in new[] { "To", "Cc" })
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<OutputType>WinExe</OutputType>
|
<OutputType>WinExe</OutputType>
|
||||||
@@ -20,4 +20,10 @@
|
|||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<None Update="appsettings.json">
|
||||||
|
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||||
|
</None>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
77
src/SpamGuard/State/BlockedDomainStore.cs
Normal file
77
src/SpamGuard/State/BlockedDomainStore.cs
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
namespace SpamGuard.State;
|
||||||
|
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
public sealed class BlockedDomainStore
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly HashSet<string> _domains;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
|
||||||
|
public BlockedDomainStore(string dataDirectory)
|
||||||
|
{
|
||||||
|
_filePath = Path.Combine(dataDirectory, "blocked-domains.json");
|
||||||
|
_domains = Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public int Count
|
||||||
|
{
|
||||||
|
get { lock (_lock) { return _domains.Count; } }
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool IsBlocked(string email)
|
||||||
|
{
|
||||||
|
var domain = ExtractDomain(email);
|
||||||
|
if (string.IsNullOrEmpty(domain)) return false;
|
||||||
|
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _domains.Contains(domain);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(string domain)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_domains.Add(Normalize(domain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Remove(string domain)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_domains.Remove(Normalize(domain));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_domains);
|
||||||
|
var tempPath = _filePath + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
File.Move(tempPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string ExtractDomain(string email)
|
||||||
|
{
|
||||||
|
var at = email.IndexOf('@');
|
||||||
|
return at >= 0 ? Normalize(email[(at + 1)..]) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string domain) => domain.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
private HashSet<string> Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return new HashSet<string>();
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_filePath);
|
||||||
|
return JsonSerializer.Deserialize<HashSet<string>>(json)
|
||||||
|
?? new HashSet<string>();
|
||||||
|
}
|
||||||
|
}
|
||||||
85
src/SpamGuard/State/OverrideStore.cs
Normal file
85
src/SpamGuard/State/OverrideStore.cs
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
namespace SpamGuard.State;
|
||||||
|
|
||||||
|
using System.Text;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
public sealed record OverrideRecord(
|
||||||
|
DateTime Timestamp,
|
||||||
|
string Sender,
|
||||||
|
string Subject,
|
||||||
|
string? BodySnippet,
|
||||||
|
string UserVerdict,
|
||||||
|
string OriginalVerdict
|
||||||
|
);
|
||||||
|
|
||||||
|
public sealed class OverrideStore
|
||||||
|
{
|
||||||
|
private readonly string _filePath;
|
||||||
|
private readonly List<OverrideRecord> _overrides;
|
||||||
|
private readonly object _lock = new();
|
||||||
|
private const int MaxOverrides = 50;
|
||||||
|
|
||||||
|
public OverrideStore(string dataDirectory)
|
||||||
|
{
|
||||||
|
_filePath = Path.Combine(dataDirectory, "overrides.json");
|
||||||
|
_overrides = Load();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Add(OverrideRecord record)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
_overrides.Add(record);
|
||||||
|
while (_overrides.Count > MaxOverrides)
|
||||||
|
_overrides.RemoveAt(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<OverrideRecord> GetRecent(int count = 10)
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
return _overrides
|
||||||
|
.OrderByDescending(o => o.Timestamp)
|
||||||
|
.Take(count)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public string BuildFewShotText(int count = 10)
|
||||||
|
{
|
||||||
|
var recent = GetRecent(count);
|
||||||
|
if (recent.Count == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
var sb = new StringBuilder();
|
||||||
|
sb.AppendLine();
|
||||||
|
sb.AppendLine("The user has previously corrected these classifications:");
|
||||||
|
foreach (var o in recent)
|
||||||
|
{
|
||||||
|
sb.AppendLine($"- [From: {o.Sender}, Subject: {o.Subject}] was originally classified as {o.OriginalVerdict} but the user marked it as {o.UserVerdict}.");
|
||||||
|
}
|
||||||
|
return sb.ToString();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Save()
|
||||||
|
{
|
||||||
|
lock (_lock)
|
||||||
|
{
|
||||||
|
var json = JsonSerializer.Serialize(_overrides, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
var tempPath = _filePath + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
File.Move(tempPath, _filePath, overwrite: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<OverrideRecord> Load()
|
||||||
|
{
|
||||||
|
if (!File.Exists(_filePath))
|
||||||
|
return new List<OverrideRecord>();
|
||||||
|
|
||||||
|
var json = File.ReadAllText(_filePath);
|
||||||
|
return JsonSerializer.Deserialize<List<OverrideRecord>>(json)
|
||||||
|
?? new List<OverrideRecord>();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,9 +9,10 @@ public sealed class ProcessedUidStore
|
|||||||
private readonly Dictionary<uint, DateTime> _uids;
|
private readonly Dictionary<uint, DateTime> _uids;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
public ProcessedUidStore(string dataDirectory)
|
public ProcessedUidStore(string dataDirectory, string accountName)
|
||||||
{
|
{
|
||||||
_filePath = Path.Combine(dataDirectory, "processed-uids.json");
|
var safeName = string.Concat(accountName.Select(c => Path.GetInvalidFileNameChars().Contains(c) ? '_' : c));
|
||||||
|
_filePath = Path.Combine(dataDirectory, $"processed-uids-{safeName}.json");
|
||||||
_uids = Load();
|
_uids = Load();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ public class ProcessedUidStoreTests : IDisposable
|
|||||||
{
|
{
|
||||||
_tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}");
|
_tempDir = Path.Combine(Path.GetTempPath(), $"spamguard-test-{Guid.NewGuid()}");
|
||||||
Directory.CreateDirectory(_tempDir);
|
Directory.CreateDirectory(_tempDir);
|
||||||
_store = new ProcessedUidStore(_tempDir);
|
_store = new ProcessedUidStore(_tempDir, "TestAccount");
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
@@ -50,7 +50,7 @@ public class ProcessedUidStoreTests : IDisposable
|
|||||||
_store.Add(3);
|
_store.Add(3);
|
||||||
_store.Save();
|
_store.Save();
|
||||||
|
|
||||||
var loaded = new ProcessedUidStore(_tempDir);
|
var loaded = new ProcessedUidStore(_tempDir, "TestAccount");
|
||||||
Assert.True(loaded.Contains(1));
|
Assert.True(loaded.Contains(1));
|
||||||
Assert.True(loaded.Contains(2));
|
Assert.True(loaded.Contains(2));
|
||||||
Assert.True(loaded.Contains(3));
|
Assert.True(loaded.Contains(3));
|
||||||
|
|||||||
Reference in New Issue
Block a user