Compare commits
16 Commits
b45d6dffc1
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 387c0dc155 | |||
| 8aa35469fc | |||
| 5c801cef4b | |||
| b5f8b7300b | |||
| 27c6d12183 | |||
| 4d8342b658 | |||
| 66cca61b21 | |||
| cd9adc5a54 | |||
| 98e2da745a | |||
| f907f9e8f1 | |||
| a3a8f2e4be | |||
| 3401cfce34 | |||
| 807b3ebb7f | |||
| 7568d3d288 | |||
| bd42cc3382 | |||
| 78f5ca864d |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -482,3 +482,6 @@ $RECYCLE.BIN/
|
||||
|
||||
# Vim temporary swap files
|
||||
*.swp
|
||||
|
||||
# User configuration with secrets
|
||||
src/SpamGuard/appsettings.json
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
33
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
33
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
@@ -0,0 +1,33 @@
|
||||
namespace SpamGuard.Configuration;
|
||||
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
public sealed class SpamGuardOptionsValidator : IValidateOptions<SpamGuardOptions>
|
||||
{
|
||||
public ValidateOptionsResult Validate(string? name, SpamGuardOptions options)
|
||||
{
|
||||
var errors = new List<string>();
|
||||
|
||||
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.");
|
||||
|
||||
return errors.Count > 0
|
||||
? ValidateOptionsResult.Fail(errors)
|
||||
: ValidateOptionsResult.Success;
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -1,2 +1,150 @@
|
||||
// See https://aka.ms/new-console-template for more information
|
||||
Console.WriteLine("Hello, World!");
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Serilog;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Services;
|
||||
using SpamGuard.State;
|
||||
using SpamGuard.Tray;
|
||||
|
||||
#pragma warning disable CA1416 // Validate platform compatibility
|
||||
|
||||
namespace SpamGuard;
|
||||
|
||||
static class Program
|
||||
{
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||
|
||||
var dataDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
|
||||
"SpamGuard");
|
||||
Directory.CreateDirectory(dataDir);
|
||||
|
||||
Log.Logger = new LoggerConfiguration()
|
||||
.WriteTo.File(
|
||||
Path.Combine(dataDir, "logs", "spamguard-.log"),
|
||||
rollingInterval: RollingInterval.Day,
|
||||
retainedFileCountLimit: 7)
|
||||
.CreateLogger();
|
||||
|
||||
IHost? host = null;
|
||||
try
|
||||
{
|
||||
host = Host.CreateDefaultBuilder()
|
||||
.UseSerilog()
|
||||
.ConfigureAppConfiguration((context, config) =>
|
||||
{
|
||||
config.AddJsonFile(
|
||||
Path.Combine(AppContext.BaseDirectory, "appsettings.json"),
|
||||
optional: false);
|
||||
config.AddEnvironmentVariables("SPAMGUARD_");
|
||||
})
|
||||
.ConfigureServices((context, services) =>
|
||||
{
|
||||
services.Configure<SpamGuardOptions>(
|
||||
context.Configuration.GetSection(SpamGuardOptions.SectionName));
|
||||
|
||||
// Validate required configuration on startup
|
||||
services.AddSingleton<IValidateOptions<SpamGuardOptions>, SpamGuardOptionsValidator>();
|
||||
services.AddOptionsWithValidateOnStart<SpamGuardOptions>();
|
||||
|
||||
var opts = context.Configuration
|
||||
.GetSection(SpamGuardOptions.SectionName)
|
||||
.Get<SpamGuardOptions>()!;
|
||||
|
||||
// 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
|
||||
services.AddHttpClient<EmailClassifier>(client =>
|
||||
{
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
client.BaseAddress = new Uri(opts.Claude.BaseUrl);
|
||||
});
|
||||
|
||||
// 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));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Fatal(ex, "Application failed to start");
|
||||
}
|
||||
finally
|
||||
{
|
||||
host?.StopAsync().GetAwaiter().GetResult();
|
||||
host?.Dispose();
|
||||
Log.CloseAndFlush();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <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;
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
167
src/SpamGuard/Services/EmailClassifier.cs
Normal file
167
src/SpamGuard/Services/EmailClassifier.cs
Normal file
@@ -0,0 +1,167 @@
|
||||
// src/SpamGuard/Services/EmailClassifier.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
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 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 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
|
||||
|
||||
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,
|
||||
OverrideStore overrideStore)
|
||||
{
|
||||
_options = options.Value;
|
||||
_logger = logger;
|
||||
_httpClient = httpClient;
|
||||
_overrideStore = overrideStore;
|
||||
}
|
||||
|
||||
public string BuildPrompt(EmailSummary email)
|
||||
{
|
||||
var body = email.BodySnippet.Length > _options.Claude.MaxBodyLength
|
||||
? email.BodySnippet[.._options.Claude.MaxBodyLength]
|
||||
: email.BodySnippet;
|
||||
|
||||
return $"""
|
||||
Email details:
|
||||
From: {email.From}
|
||||
Subject: {email.Subject}
|
||||
Body: {body}
|
||||
""";
|
||||
}
|
||||
|
||||
public async Task<ClassificationResult?> ClassifyAsync(EmailSummary email, CancellationToken ct = default)
|
||||
{
|
||||
var userMessage = BuildPrompt(email);
|
||||
|
||||
_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,
|
||||
messages = new[]
|
||||
{
|
||||
new { role = "user", content = userMessage }
|
||||
}
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(requestBody);
|
||||
var request = new HttpRequestMessage(HttpMethod.Post, "v1/messages")
|
||||
{
|
||||
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("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);
|
||||
|
||||
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);
|
||||
var text = doc.RootElement
|
||||
.GetProperty("content")[0]
|
||||
.GetProperty("text")
|
||||
.GetString() ?? "";
|
||||
|
||||
var result = ParseResponse(text);
|
||||
|
||||
if (result != null)
|
||||
_logger.LogInformation(
|
||||
"UID={Uid} classified as {Classification} (confidence={Confidence}): {Reason}",
|
||||
email.Uid, result.Classification, result.Confidence, result.Reason);
|
||||
else
|
||||
_logger.LogWarning("UID={Uid} classification failed to parse: {Text}", email.Uid, text);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static ClassificationResult? ParseResponse(string text)
|
||||
{
|
||||
// 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(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
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.Compiled)]
|
||||
private static partial Regex StripMarkdownFencing();
|
||||
}
|
||||
30
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
30
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit.Net.Imap;
|
||||
using MailKit.Security;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using SpamGuard.Configuration;
|
||||
|
||||
public sealed class ImapClientFactory
|
||||
{
|
||||
private readonly ILogger<ImapClientFactory> _logger;
|
||||
|
||||
public ImapClientFactory(ILogger<ImapClientFactory> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<ImapClient> CreateConnectedClientAsync(ImapOptions imap, CancellationToken ct = default)
|
||||
{
|
||||
var client = new ImapClient();
|
||||
|
||||
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
|
||||
|
||||
var secureSocketOptions = imap.UseSsl ? SecureSocketOptions.SslOnConnect : SecureSocketOptions.StartTlsWhenAvailable;
|
||||
await client.ConnectAsync(imap.Host, imap.Port, secureSocketOptions, ct);
|
||||
await client.AuthenticateAsync(imap.Username, imap.Password, ct);
|
||||
|
||||
_logger.LogDebug("Connected and authenticated as {Username}", imap.Username);
|
||||
return client;
|
||||
}
|
||||
}
|
||||
381
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
381
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
@@ -0,0 +1,381 @@
|
||||
// src/SpamGuard/Services/InboxMonitorService.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit;
|
||||
using MailKit.Search;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.State;
|
||||
|
||||
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 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;
|
||||
|
||||
public InboxMonitorService(
|
||||
ImapClientFactory imapFactory,
|
||||
TrustedSenderStore trustedSenders,
|
||||
BlockedDomainStore blockedDomains,
|
||||
ProcessedUidStore processedUids,
|
||||
EmailClassifier classifier,
|
||||
ActivityLog activityLog,
|
||||
ImapOptions imap,
|
||||
MonitoringOptions monitoring,
|
||||
ILogger<InboxMonitorService> logger)
|
||||
{
|
||||
_imapFactory = imapFactory;
|
||||
_trustedSenders = trustedSenders;
|
||||
_blockedDomains = blockedDomains;
|
||||
_processedUids = processedUids;
|
||||
_classifier = classifier;
|
||||
_activityLog = activityLog;
|
||||
_imap = imap;
|
||||
_monitoring = monitoring;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("InboxMonitorService started for {Account}", AccountName);
|
||||
|
||||
// Brief delay to let TrustedSenderService do its first scan
|
||||
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
if (!_paused)
|
||||
{
|
||||
try
|
||||
{
|
||||
await PollInboxAsync(stoppingToken);
|
||||
_processedUids.Prune(TimeSpan.FromDays(30));
|
||||
_processedUids.Save();
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error polling inbox");
|
||||
_activityLog.Add(new ActivityEntry
|
||||
{
|
||||
Timestamp = DateTime.UtcNow, Sender = "", Subject = "",
|
||||
Verdict = Verdict.Error, Reason = ex.Message, AccountName = AccountName
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromSeconds(_monitoring.PollIntervalSeconds), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PollInboxAsync(CancellationToken ct)
|
||||
{
|
||||
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
|
||||
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(-_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 (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);
|
||||
}
|
||||
|
||||
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
|
||||
}
|
||||
|
||||
await client.DisconnectAsync(true, ct);
|
||||
}
|
||||
|
||||
private async Task ProcessMessageAsync(
|
||||
IMailFolder inbox, UniqueId uid, IMailFolder? spamFolder, CancellationToken ct)
|
||||
{
|
||||
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
|
||||
{
|
||||
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;
|
||||
}
|
||||
|
||||
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
|
||||
|
||||
// Classify
|
||||
var result = await _classifier.ClassifyAsync(emailSummary, ct);
|
||||
|
||||
if (result == null)
|
||||
{
|
||||
_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 >= _monitoring.SpamConfidenceThreshold)
|
||||
{
|
||||
if (spamFolder != null)
|
||||
{
|
||||
await inbox.MoveToAsync(uid, spamFolder, ct);
|
||||
_logger.LogInformation("Moved UID={Uid} to spam: {Reason}", uid.Id, result.Reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("Spam detected but no spam folder found, flagging instead");
|
||||
await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
|
||||
}
|
||||
|
||||
_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)
|
||||
{
|
||||
_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
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static string ExtractBodySnippet(MimeMessage message)
|
||||
{
|
||||
var text = message.TextBody;
|
||||
|
||||
if (text == null && message.HtmlBody != null)
|
||||
{
|
||||
// Strip script and style blocks first, then remaining tags
|
||||
text = StripScriptStyle().Replace(message.HtmlBody, " ");
|
||||
text = StripHtmlTags().Replace(text, " ");
|
||||
text = System.Net.WebUtility.HtmlDecode(text);
|
||||
text = CollapseWhitespace().Replace(text, " ").Trim();
|
||||
}
|
||||
|
||||
text ??= "";
|
||||
return text.Length > 2000 ? text[..2000] : text;
|
||||
}
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"<(script|style)[^>]*>[\s\S]*?</\1>", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
|
||||
private static partial System.Text.RegularExpressions.Regex StripScriptStyle();
|
||||
|
||||
[System.Text.RegularExpressions.GeneratedRegex(@"<[^>]+>")]
|
||||
private static partial System.Text.RegularExpressions.Regex StripHtmlTags();
|
||||
|
||||
[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)
|
||||
{
|
||||
// 1. Try IMAP special-use \Junk attribute (most reliable when server advertises it)
|
||||
try
|
||||
{
|
||||
var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
|
||||
if (junk != null)
|
||||
{
|
||||
_logger.LogDebug("Found spam folder via special-use attribute: {Full}", junk.FullName);
|
||||
return junk;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Server does not advertise Junk special-use attribute");
|
||||
}
|
||||
|
||||
// 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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
137
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
137
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
@@ -0,0 +1,137 @@
|
||||
// src/SpamGuard/Services/TrustedSenderService.cs
|
||||
namespace SpamGuard.Services;
|
||||
|
||||
using MailKit;
|
||||
using MailKit.Search;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using MimeKit;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.State;
|
||||
|
||||
public sealed class TrustedSenderService : BackgroundService
|
||||
{
|
||||
private readonly ImapClientFactory _imapFactory;
|
||||
private readonly TrustedSenderStore _store;
|
||||
private readonly ImapOptions _imap;
|
||||
private readonly MonitoringOptions _monitoring;
|
||||
private readonly ILogger<TrustedSenderService> _logger;
|
||||
private bool _initialScanDone;
|
||||
|
||||
public TrustedSenderService(
|
||||
ImapClientFactory imapFactory,
|
||||
TrustedSenderStore store,
|
||||
ImapOptions imap,
|
||||
MonitoringOptions monitoring,
|
||||
ILogger<TrustedSenderService> logger)
|
||||
{
|
||||
_imapFactory = imapFactory;
|
||||
_store = store;
|
||||
_imap = imap;
|
||||
_monitoring = monitoring;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("TrustedSenderService started for {Account}", _imap.Username);
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await ScanSentFolderAsync(stoppingToken);
|
||||
_store.Save();
|
||||
_logger.LogInformation("Trusted sender scan complete. {Count} trusted senders", _store.Count);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error scanning sent folder");
|
||||
}
|
||||
|
||||
await Task.Delay(TimeSpan.FromMinutes(_monitoring.TrustedSenderRefreshMinutes), stoppingToken);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ScanSentFolderAsync(CancellationToken ct)
|
||||
{
|
||||
using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct);
|
||||
|
||||
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(-_monitoring.TrustedSenderRefreshMinutes))
|
||||
: SearchQuery.All;
|
||||
|
||||
var uids = await sentFolder.SearchAsync(query, ct);
|
||||
_logger.LogDebug("Found {Count} messages in Sent folder to scan", uids.Count);
|
||||
|
||||
var addresses = new List<string>();
|
||||
foreach (var uid in uids)
|
||||
{
|
||||
var headers = await sentFolder.GetHeadersAsync(uid, ct);
|
||||
ExtractAddresses(headers, addresses);
|
||||
}
|
||||
|
||||
_store.AddRange(addresses);
|
||||
_initialScanDone = true;
|
||||
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" })
|
||||
{
|
||||
var value = headers[headerName];
|
||||
if (string.IsNullOrEmpty(value)) continue;
|
||||
|
||||
if (InternetAddressList.TryParse(value, out var list))
|
||||
{
|
||||
foreach (var address in list.Mailboxes)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(address.Address))
|
||||
addresses.Add(address.Address);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
@@ -12,9 +12,18 @@
|
||||
<PackageReference Include="MailKit" Version="4.*" />
|
||||
<PackageReference Include="Anthropic" Version="12.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="8.*" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http" Version="8.*" />
|
||||
<PackageReference Include="Serilog.Extensions.Hosting" Version="8.*" />
|
||||
<PackageReference Include="Serilog.Sinks.File" Version="5.*" />
|
||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="appsettings.json">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
</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 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();
|
||||
}
|
||||
|
||||
@@ -52,7 +53,9 @@ public sealed class ProcessedUidStore
|
||||
lock (_lock)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_uids);
|
||||
File.WriteAllText(_filePath, json);
|
||||
var tempPath = _filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,9 @@ public sealed class TrustedSenderStore
|
||||
lock (_lock)
|
||||
{
|
||||
var json = JsonSerializer.Serialize(_senders);
|
||||
File.WriteAllText(_filePath, json);
|
||||
var tempPath = _filePath + ".tmp";
|
||||
File.WriteAllText(tempPath, json);
|
||||
File.Move(tempPath, _filePath, overwrite: true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
355
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
355
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
@@ -0,0 +1,355 @@
|
||||
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;
|
||||
|
||||
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(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,
|
||||
ReadOnly = true,
|
||||
AllowUserToAddRows = false,
|
||||
AllowUserToDeleteRows = false,
|
||||
AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill,
|
||||
SelectionMode = DataGridViewSelectionMode.FullRowSelect,
|
||||
BackgroundColor = System.Drawing.SystemColors.Window,
|
||||
BorderStyle = BorderStyle.None,
|
||||
ContextMenuStrip = _rowMenu
|
||||
};
|
||||
|
||||
_grid.Columns.Add("Time", "Time");
|
||||
_grid.Columns.Add("From", "From");
|
||||
_grid.Columns.Add("Subject", "Subject");
|
||||
_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)
|
||||
{
|
||||
Invoke(RefreshData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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 ?? "",
|
||||
overrideMarker
|
||||
);
|
||||
|
||||
var row = _grid.Rows[rowIndex];
|
||||
row.Tag = entry;
|
||||
|
||||
row.DefaultCellStyle.ForeColor = entry.Verdict switch
|
||||
{
|
||||
Verdict.Spam => System.Drawing.Color.Red,
|
||||
Verdict.Trusted => System.Drawing.Color.Green,
|
||||
Verdict.Uncertain => System.Drawing.Color.Orange,
|
||||
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)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
else
|
||||
{
|
||||
base.OnFormClosing(e);
|
||||
}
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
_activityLog.EntryChanged -= RefreshData;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
39
src/SpamGuard/Tray/IconGenerator.cs
Normal file
39
src/SpamGuard/Tray/IconGenerator.cs
Normal file
@@ -0,0 +1,39 @@
|
||||
namespace SpamGuard.Tray;
|
||||
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
public static class IconGenerator
|
||||
{
|
||||
private static readonly Lazy<Icon> _green = new(() => CreateCircleIcon(Color.FromArgb(76, 175, 80)));
|
||||
private static readonly Lazy<Icon> _yellow = new(() => CreateCircleIcon(Color.FromArgb(255, 193, 7)));
|
||||
private static readonly Lazy<Icon> _red = new(() => CreateCircleIcon(Color.FromArgb(244, 67, 54)));
|
||||
|
||||
public static Icon Green => _green.Value;
|
||||
public static Icon Yellow => _yellow.Value;
|
||||
public static Icon Red => _red.Value;
|
||||
|
||||
[DllImport("user32.dll", SetLastError = true)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
private static extern bool DestroyIcon(IntPtr handle);
|
||||
|
||||
private static Icon CreateCircleIcon(Color color, int size = 16)
|
||||
{
|
||||
using var bitmap = new Bitmap(size, size);
|
||||
using var graphics = Graphics.FromImage(bitmap);
|
||||
graphics.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
graphics.Clear(Color.Transparent);
|
||||
|
||||
using var brush = new SolidBrush(color);
|
||||
graphics.FillEllipse(brush, 1, 1, size - 2, size - 2);
|
||||
|
||||
using var pen = new Pen(Color.FromArgb(100, 0, 0, 0), 1);
|
||||
graphics.DrawEllipse(pen, 1, 1, size - 3, size - 3);
|
||||
|
||||
var hIcon = bitmap.GetHicon();
|
||||
var icon = (Icon)Icon.FromHandle(hIcon).Clone();
|
||||
DestroyIcon(hIcon);
|
||||
return icon;
|
||||
}
|
||||
}
|
||||
121
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
121
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
@@ -0,0 +1,121 @@
|
||||
// src/SpamGuard/Tray/TrayApplicationContext.cs
|
||||
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 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>();
|
||||
_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);
|
||||
|
||||
var contextMenu = new ContextMenuStrip();
|
||||
contextMenu.Items.Add("View Activity Log", null, OnViewLog);
|
||||
contextMenu.Items.Add(_pauseMenuItem);
|
||||
contextMenu.Items.Add(new ToolStripSeparator());
|
||||
contextMenu.Items.Add("Quit", null, OnQuit);
|
||||
|
||||
_notifyIcon = new NotifyIcon
|
||||
{
|
||||
Icon = IconGenerator.Green,
|
||||
Text = "SpamGuard - Starting...",
|
||||
Visible = true,
|
||||
ContextMenuStrip = contextMenu
|
||||
};
|
||||
|
||||
_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();
|
||||
}
|
||||
|
||||
private void OnRefreshTick(object? sender, EventArgs e)
|
||||
{
|
||||
var checked_ = _activityLog.TodayChecked;
|
||||
var spam = _activityLog.TodaySpam;
|
||||
var tooltip = $"SpamGuard - {checked_} checked, {spam} spam caught today";
|
||||
_notifyIcon.Text = tooltip.Length > 127 ? tooltip[..127] : tooltip;
|
||||
|
||||
if (!AllPaused)
|
||||
{
|
||||
var recent = _activityLog.GetRecent(1);
|
||||
var hasRecentError = recent.Count > 0
|
||||
&& recent[0].Verdict == Models.Verdict.Error
|
||||
&& recent[0].Timestamp > DateTime.UtcNow.AddMinutes(-5);
|
||||
_notifyIcon.Icon = hasRecentError ? IconGenerator.Red : IconGenerator.Green;
|
||||
}
|
||||
|
||||
// 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, _monitors, _trustedSenders, _blockedDomains, _overrideStore);
|
||||
}
|
||||
_logForm.Show();
|
||||
_logForm.BringToFront();
|
||||
}
|
||||
|
||||
private void OnPauseResume(object? sender, EventArgs e)
|
||||
{
|
||||
if (AllPaused)
|
||||
{
|
||||
foreach (var m in _monitors) m.Resume();
|
||||
_pauseMenuItem.Text = "Pause";
|
||||
_notifyIcon.Icon = IconGenerator.Green;
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var m in _monitors) m.Pause();
|
||||
_pauseMenuItem.Text = "Resume";
|
||||
_notifyIcon.Icon = IconGenerator.Yellow;
|
||||
}
|
||||
}
|
||||
|
||||
private async void OnQuit(object? sender, EventArgs e)
|
||||
{
|
||||
_notifyIcon.Visible = false;
|
||||
_refreshTimer.Stop();
|
||||
await _host.StopAsync();
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_refreshTimer.Dispose();
|
||||
_notifyIcon.Dispose();
|
||||
_logForm?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
@@ -1,15 +1,27 @@
|
||||
{
|
||||
"SpamGuard": {
|
||||
"Imap": {
|
||||
"Host": "imap.example.com",
|
||||
"Accounts": [
|
||||
{
|
||||
"Name": "Work",
|
||||
"Host": "imap.dynu.com",
|
||||
"Port": 993,
|
||||
"UseSsl": true,
|
||||
"Username": "user@example.com",
|
||||
"Password": ""
|
||||
"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": {
|
||||
|
||||
38
src/SpamGuard/appsettings.template.json
Normal file
38
src/SpamGuard/appsettings.template.json
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"SpamGuard": {
|
||||
"Imap": {
|
||||
"Host": "imap.example.com",
|
||||
"Port": 993,
|
||||
"UseSsl": true,
|
||||
"Username": "user@example.com",
|
||||
"Password": ""
|
||||
},
|
||||
"Claude": {
|
||||
"ApiKey": "",
|
||||
"Model": "claude-sonnet-4-6",
|
||||
"MaxBodyLength": 2000
|
||||
},
|
||||
"Monitoring": {
|
||||
"PollIntervalSeconds": 60,
|
||||
"TrustedSenderRefreshMinutes": 60,
|
||||
"SpamConfidenceThreshold": 0.7,
|
||||
"SpamFolderName": "Junk",
|
||||
"InitialScanDays": 7,
|
||||
"MaxActivityLogEntries": 500
|
||||
}
|
||||
},
|
||||
"Serilog": {
|
||||
"MinimumLevel": "Information",
|
||||
"WriteTo": [
|
||||
{ "Name": "Console" },
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "%APPDATA%/SpamGuard/logs/spamguard-.log",
|
||||
"rollingInterval": "Day",
|
||||
"retainedFileCountLimit": 7
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
103
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
103
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
@@ -0,0 +1,103 @@
|
||||
// tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
|
||||
namespace SpamGuard.Tests.Services;
|
||||
|
||||
using System.Net;
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using SpamGuard.Configuration;
|
||||
using SpamGuard.Models;
|
||||
using SpamGuard.Services;
|
||||
using SpamGuard.State;
|
||||
|
||||
public class EmailClassifierTests
|
||||
{
|
||||
private static SpamGuardOptions DefaultOptions => new()
|
||||
{
|
||||
Claude = new ClaudeOptions
|
||||
{
|
||||
ApiKey = "test-key",
|
||||
Model = "claude-sonnet-4-6",
|
||||
MaxBodyLength = 2000
|
||||
}
|
||||
};
|
||||
|
||||
private static EmailSummary SampleEmail => new(
|
||||
Uid: 1,
|
||||
From: "spammer@sketchy.com",
|
||||
Subject: "Buy now! Limited offer!",
|
||||
BodySnippet: "Click here to claim your prize...",
|
||||
Date: DateTimeOffset.UtcNow
|
||||
);
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_ContainsSenderAndSubjectAndBody()
|
||||
{
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient(),
|
||||
new OverrideStore(Path.GetTempPath())
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(SampleEmail);
|
||||
|
||||
Assert.Contains("spammer@sketchy.com", prompt);
|
||||
Assert.Contains("Buy now! Limited offer!", prompt);
|
||||
Assert.Contains("Click here to claim your prize...", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPrompt_TruncatesLongBody()
|
||||
{
|
||||
var longBody = new string('x', 5000);
|
||||
var email = SampleEmail with { BodySnippet = longBody };
|
||||
|
||||
var classifier = new EmailClassifier(
|
||||
Options.Create(DefaultOptions),
|
||||
new NullLogger<EmailClassifier>(),
|
||||
new HttpClient(),
|
||||
new OverrideStore(Path.GetTempPath())
|
||||
);
|
||||
|
||||
var prompt = classifier.BuildPrompt(email);
|
||||
|
||||
// Body in prompt should be truncated to MaxBodyLength
|
||||
Assert.DoesNotContain(longBody, prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_ValidJson_ReturnsResult()
|
||||
{
|
||||
var json = """{"classification": "spam", "confidence": 0.95, "reason": "Unsolicited marketing"}""";
|
||||
|
||||
var result = EmailClassifier.ParseResponse(json);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.True(result.IsSpam);
|
||||
Assert.Equal(0.95, result.Confidence);
|
||||
Assert.Equal("Unsolicited marketing", result.Reason);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_InvalidJson_ReturnsNull()
|
||||
{
|
||||
var result = EmailClassifier.ParseResponse("not json at all");
|
||||
Assert.Null(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ParseResponse_JsonWithMarkdownFencing_ReturnsResult()
|
||||
{
|
||||
var json = """
|
||||
```json
|
||||
{"classification": "legitimate", "confidence": 0.85, "reason": "Normal business email"}
|
||||
```
|
||||
""";
|
||||
|
||||
var result = EmailClassifier.ParseResponse(json);
|
||||
|
||||
Assert.NotNull(result);
|
||||
Assert.False(result.IsSpam);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace SpamGuard.Tests;
|
||||
|
||||
public class UnitTest1
|
||||
{
|
||||
[Fact]
|
||||
public void Test1()
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user