Compare commits
10 Commits
b45d6dffc1
...
66cca61b21
| Author | SHA1 | Date | |
|---|---|---|---|
| 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
|
# Vim temporary swap files
|
||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
|
# User configuration with secrets
|
||||||
|
src/SpamGuard/appsettings.json
|
||||||
|
|||||||
24
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
24
src/SpamGuard/Configuration/SpamGuardOptionsValidator.cs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
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 (string.IsNullOrWhiteSpace(options.Imap.Host))
|
||||||
|
errors.Add("SpamGuard:Imap:Host is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Imap.Username))
|
||||||
|
errors.Add("SpamGuard:Imap:Username is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Imap.Password))
|
||||||
|
errors.Add("SpamGuard:Imap:Password is required.");
|
||||||
|
if (string.IsNullOrWhiteSpace(options.Claude.ApiKey))
|
||||||
|
errors.Add("SpamGuard:Claude:ApiKey is required.");
|
||||||
|
|
||||||
|
return errors.Count > 0
|
||||||
|
? ValidateOptionsResult.Fail(errors)
|
||||||
|
: ValidateOptionsResult.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,2 +1,96 @@
|
|||||||
// See https://aka.ms/new-console-template for more information
|
using Microsoft.Extensions.Configuration;
|
||||||
Console.WriteLine("Hello, World!");
|
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;
|
||||||
|
|
||||||
|
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>();
|
||||||
|
|
||||||
|
// State stores
|
||||||
|
services.AddSingleton(new ProcessedUidStore(dataDir));
|
||||||
|
services.AddSingleton(new TrustedSenderStore(dataDir));
|
||||||
|
|
||||||
|
// Services
|
||||||
|
services.AddSingleton(sp =>
|
||||||
|
{
|
||||||
|
var opts = sp.GetRequiredService<IOptions<SpamGuardOptions>>().Value;
|
||||||
|
return new ActivityLog(opts.Monitoring.MaxActivityLogEntries);
|
||||||
|
});
|
||||||
|
services.AddSingleton<ImapClientFactory>();
|
||||||
|
|
||||||
|
// EmailClassifier with managed HttpClient (timeout + base address)
|
||||||
|
services.AddHttpClient<EmailClassifier>(client =>
|
||||||
|
{
|
||||||
|
client.Timeout = TimeSpan.FromSeconds(30);
|
||||||
|
client.BaseAddress = new Uri("https://api.anthropic.com/");
|
||||||
|
});
|
||||||
|
|
||||||
|
// Background services
|
||||||
|
services.AddSingleton<InboxMonitorService>();
|
||||||
|
services.AddHostedService(sp => sp.GetRequiredService<InboxMonitorService>());
|
||||||
|
services.AddHostedService<TrustedSenderService>();
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
130
src/SpamGuard/Services/EmailClassifier.cs
Normal file
130
src/SpamGuard/Services/EmailClassifier.cs
Normal file
@@ -0,0 +1,130 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
public sealed partial class EmailClassifier
|
||||||
|
{
|
||||||
|
private readonly SpamGuardOptions _options;
|
||||||
|
private readonly ILogger<EmailClassifier> _logger;
|
||||||
|
private readonly HttpClient _httpClient;
|
||||||
|
|
||||||
|
private const string SystemPrompt = """
|
||||||
|
You are an email spam classifier. Analyze the following email and determine if it is spam or legitimate.
|
||||||
|
|
||||||
|
Spam includes:
|
||||||
|
- Unsolicited marketing or promotional emails the recipient never signed up for
|
||||||
|
- AI-generated emails designed to look like legitimate correspondence
|
||||||
|
- Newsletter signups the recipient didn't request
|
||||||
|
|
||||||
|
Legitimate includes:
|
||||||
|
- Emails from known contacts or businesses the recipient has a relationship with
|
||||||
|
- Transactional emails (receipts, shipping notifications, password resets)
|
||||||
|
- Emails the recipient would expect to receive
|
||||||
|
|
||||||
|
Respond with JSON only:
|
||||||
|
{"classification": "spam" | "legitimate", "confidence": 0.0-1.0, "reason": "brief explanation"}
|
||||||
|
""";
|
||||||
|
|
||||||
|
public EmailClassifier(
|
||||||
|
IOptions<SpamGuardOptions> options,
|
||||||
|
ILogger<EmailClassifier> logger,
|
||||||
|
HttpClient httpClient)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
_httpClient = httpClient;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
};
|
||||||
|
request.Headers.Add("x-api-key", _options.Claude.ApiKey);
|
||||||
|
request.Headers.Add("anthropic-version", "2023-06-01");
|
||||||
|
|
||||||
|
var response = await _httpClient.SendAsync(request, ct);
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
|
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
|
||||||
|
{
|
||||||
|
using var doc = JsonDocument.Parse(cleaned);
|
||||||
|
var root = doc.RootElement;
|
||||||
|
|
||||||
|
return new ClassificationResult(
|
||||||
|
Classification: root.GetProperty("classification").GetString() ?? "unknown",
|
||||||
|
Confidence: root.GetProperty("confidence").GetDouble(),
|
||||||
|
Reason: root.GetProperty("reason").GetString() ?? ""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[GeneratedRegex(@"```(?:json)?\s*([\s\S]*?)\s*```", RegexOptions.Compiled)]
|
||||||
|
private static partial Regex StripMarkdownFencing();
|
||||||
|
}
|
||||||
34
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
34
src/SpamGuard/Services/ImapClientFactory.cs
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
namespace SpamGuard.Services;
|
||||||
|
|
||||||
|
using MailKit.Net.Imap;
|
||||||
|
using MailKit.Security;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using SpamGuard.Configuration;
|
||||||
|
|
||||||
|
public sealed class ImapClientFactory
|
||||||
|
{
|
||||||
|
private readonly SpamGuardOptions _options;
|
||||||
|
private readonly ILogger<ImapClientFactory> _logger;
|
||||||
|
|
||||||
|
public ImapClientFactory(IOptions<SpamGuardOptions> options, ILogger<ImapClientFactory> logger)
|
||||||
|
{
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ImapClient> CreateConnectedClientAsync(CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
var client = new ImapClient();
|
||||||
|
var imap = _options.Imap;
|
||||||
|
|
||||||
|
_logger.LogDebug("Connecting to {Host}:{Port} (SSL={UseSsl})", imap.Host, imap.Port, imap.UseSsl);
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
256
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
256
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
// src/SpamGuard/Services/InboxMonitorService.cs
|
||||||
|
namespace SpamGuard.Services;
|
||||||
|
|
||||||
|
using MailKit;
|
||||||
|
using MailKit.Search;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MimeKit;
|
||||||
|
using SpamGuard.Configuration;
|
||||||
|
using SpamGuard.Models;
|
||||||
|
using SpamGuard.State;
|
||||||
|
|
||||||
|
public sealed partial class InboxMonitorService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ImapClientFactory _imapFactory;
|
||||||
|
private readonly TrustedSenderStore _trustedSenders;
|
||||||
|
private readonly ProcessedUidStore _processedUids;
|
||||||
|
private readonly EmailClassifier _classifier;
|
||||||
|
private readonly ActivityLog _activityLog;
|
||||||
|
private readonly SpamGuardOptions _options;
|
||||||
|
private readonly ILogger<InboxMonitorService> _logger;
|
||||||
|
|
||||||
|
private volatile bool _paused;
|
||||||
|
private uint _lastSeenUid;
|
||||||
|
|
||||||
|
public bool IsPaused => _paused;
|
||||||
|
public void Pause() => _paused = true;
|
||||||
|
public void Resume() => _paused = false;
|
||||||
|
|
||||||
|
public InboxMonitorService(
|
||||||
|
ImapClientFactory imapFactory,
|
||||||
|
TrustedSenderStore trustedSenders,
|
||||||
|
ProcessedUidStore processedUids,
|
||||||
|
EmailClassifier classifier,
|
||||||
|
ActivityLog activityLog,
|
||||||
|
IOptions<SpamGuardOptions> options,
|
||||||
|
ILogger<InboxMonitorService> logger)
|
||||||
|
{
|
||||||
|
_imapFactory = imapFactory;
|
||||||
|
_trustedSenders = trustedSenders;
|
||||||
|
_processedUids = processedUids;
|
||||||
|
_classifier = classifier;
|
||||||
|
_activityLog = activityLog;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("InboxMonitorService started");
|
||||||
|
|
||||||
|
// 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(
|
||||||
|
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(_options.Monitoring.PollIntervalSeconds), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task PollInboxAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
|
||||||
|
var inbox = client.Inbox;
|
||||||
|
await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
|
||||||
|
|
||||||
|
// Build search query: only fetch new messages
|
||||||
|
IList<UniqueId> uids;
|
||||||
|
if (_lastSeenUid > 0)
|
||||||
|
{
|
||||||
|
var range = new UniqueIdRange(new UniqueId(_lastSeenUid + 1), UniqueId.MaxValue);
|
||||||
|
uids = await inbox.SearchAsync(range, SearchQuery.All, ct);
|
||||||
|
}
|
||||||
|
else if (_processedUids.Count > 0)
|
||||||
|
{
|
||||||
|
// Resuming from persisted state -- scan recent messages only
|
||||||
|
uids = await inbox.SearchAsync(
|
||||||
|
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-1)), ct);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
// First ever run -- initial scan window
|
||||||
|
uids = await inbox.SearchAsync(
|
||||||
|
SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct);
|
||||||
|
}
|
||||||
|
_logger.LogDebug("Found {Count} messages in inbox", uids.Count);
|
||||||
|
|
||||||
|
// Find the spam/junk folder
|
||||||
|
var spamFolder = await FindSpamFolderAsync(client, ct);
|
||||||
|
|
||||||
|
foreach (var uid in uids)
|
||||||
|
{
|
||||||
|
if (_processedUids.Contains(uid.Id))
|
||||||
|
{
|
||||||
|
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await ProcessMessageAsync(inbox, uid, spamFolder, ct);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
|
||||||
|
_activityLog.Add(new ActivityEntry(
|
||||||
|
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message));
|
||||||
|
_processedUids.Add(uid.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uid.Id > _lastSeenUid) _lastSeenUid = uid.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
await client.DisconnectAsync(true, ct);
|
||||||
|
}
|
||||||
|
|
||||||
|
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)";
|
||||||
|
|
||||||
|
// Check trusted senders
|
||||||
|
if (_trustedSenders.IsTrusted(from))
|
||||||
|
{
|
||||||
|
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
|
||||||
|
_activityLog.Add(new ActivityEntry(
|
||||||
|
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null));
|
||||||
|
_processedUids.Add(uid.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract body snippet
|
||||||
|
var bodySnippet = ExtractBodySnippet(message);
|
||||||
|
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(
|
||||||
|
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed"));
|
||||||
|
_processedUids.Add(uid.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold)
|
||||||
|
{
|
||||||
|
// Move to spam folder
|
||||||
|
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(
|
||||||
|
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason));
|
||||||
|
}
|
||||||
|
else if (result.IsSpam)
|
||||||
|
{
|
||||||
|
// Below threshold -- uncertain
|
||||||
|
_activityLog.Add(new ActivityEntry(
|
||||||
|
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_activityLog.Add(new ActivityEntry(
|
||||||
|
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason));
|
||||||
|
}
|
||||||
|
|
||||||
|
_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();
|
||||||
|
|
||||||
|
private async Task<IMailFolder?> FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct)
|
||||||
|
{
|
||||||
|
// Try special folder first
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var junk = client.GetFolder(MailKit.SpecialFolder.Junk);
|
||||||
|
if (junk != null) return junk;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not get Junk special folder");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to configured folder name
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var personal = client.GetFolder(client.PersonalNamespaces[0]);
|
||||||
|
var folders = await personal.GetSubfoldersAsync(false, ct);
|
||||||
|
return folders.FirstOrDefault(f =>
|
||||||
|
f.Name.Equals(_options.Monitoring.SpamFolderName, StringComparison.OrdinalIgnoreCase));
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
104
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
104
src/SpamGuard/Services/TrustedSenderService.cs
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
// src/SpamGuard/Services/TrustedSenderService.cs
|
||||||
|
namespace SpamGuard.Services;
|
||||||
|
|
||||||
|
using MailKit;
|
||||||
|
using MailKit.Search;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using MimeKit;
|
||||||
|
using SpamGuard.Configuration;
|
||||||
|
using SpamGuard.State;
|
||||||
|
|
||||||
|
public sealed class TrustedSenderService : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly ImapClientFactory _imapFactory;
|
||||||
|
private readonly TrustedSenderStore _store;
|
||||||
|
private readonly SpamGuardOptions _options;
|
||||||
|
private readonly ILogger<TrustedSenderService> _logger;
|
||||||
|
private bool _initialScanDone;
|
||||||
|
|
||||||
|
public TrustedSenderService(
|
||||||
|
ImapClientFactory imapFactory,
|
||||||
|
TrustedSenderStore store,
|
||||||
|
IOptions<SpamGuardOptions> options,
|
||||||
|
ILogger<TrustedSenderService> logger)
|
||||||
|
{
|
||||||
|
_imapFactory = imapFactory;
|
||||||
|
_store = store;
|
||||||
|
_options = options.Value;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("TrustedSenderService started");
|
||||||
|
|
||||||
|
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(_options.Monitoring.TrustedSenderRefreshMinutes), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanSentFolderAsync(CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var client = await _imapFactory.CreateConnectedClientAsync(ct);
|
||||||
|
|
||||||
|
var sentFolder = client.GetFolder(MailKit.SpecialFolder.Sent)
|
||||||
|
?? throw new InvalidOperationException("Could not find Sent folder");
|
||||||
|
|
||||||
|
await sentFolder.OpenAsync(FolderAccess.ReadOnly, ct);
|
||||||
|
|
||||||
|
// After initial full scan, only check messages from the last refresh period
|
||||||
|
var query = _initialScanDone
|
||||||
|
? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddMinutes(-_options.Monitoring.TrustedSenderRefreshMinutes))
|
||||||
|
: SearchQuery.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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@
|
|||||||
<PackageReference Include="MailKit" Version="4.*" />
|
<PackageReference Include="MailKit" Version="4.*" />
|
||||||
<PackageReference Include="Anthropic" Version="12.*" />
|
<PackageReference Include="Anthropic" Version="12.*" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="8.*" />
|
<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.Extensions.Hosting" Version="8.*" />
|
||||||
<PackageReference Include="Serilog.Sinks.File" Version="5.*" />
|
<PackageReference Include="Serilog.Sinks.File" Version="5.*" />
|
||||||
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
<PackageReference Include="Serilog.Sinks.Console" Version="5.*" />
|
||||||
|
|||||||
@@ -52,7 +52,9 @@ public sealed class ProcessedUidStore
|
|||||||
lock (_lock)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(_uids);
|
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)
|
lock (_lock)
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(_senders);
|
var json = JsonSerializer.Serialize(_senders);
|
||||||
File.WriteAllText(_filePath, json);
|
var tempPath = _filePath + ".tmp";
|
||||||
|
File.WriteAllText(tempPath, json);
|
||||||
|
File.Move(tempPath, _filePath, overwrite: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
95
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
95
src/SpamGuard/Tray/ActivityLogForm.cs
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
namespace SpamGuard.Tray;
|
||||||
|
|
||||||
|
using SpamGuard.Models;
|
||||||
|
using SpamGuard.Services;
|
||||||
|
|
||||||
|
public sealed class ActivityLogForm : Form
|
||||||
|
{
|
||||||
|
private readonly ActivityLog _activityLog;
|
||||||
|
private readonly DataGridView _grid;
|
||||||
|
|
||||||
|
public ActivityLogForm(ActivityLog activityLog)
|
||||||
|
{
|
||||||
|
_activityLog = activityLog;
|
||||||
|
|
||||||
|
Text = "SpamGuard - Activity Log";
|
||||||
|
Size = new System.Drawing.Size(800, 500);
|
||||||
|
StartPosition = FormStartPosition.CenterScreen;
|
||||||
|
Icon = IconGenerator.Green;
|
||||||
|
|
||||||
|
_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
|
||||||
|
};
|
||||||
|
|
||||||
|
_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["Time"]!.Width = 70;
|
||||||
|
_grid.Columns["From"]!.Width = 150;
|
||||||
|
_grid.Columns["Verdict"]!.Width = 80;
|
||||||
|
_grid.Columns["Confidence"]!.Width = 80;
|
||||||
|
|
||||||
|
Controls.Add(_grid);
|
||||||
|
|
||||||
|
RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void RefreshData()
|
||||||
|
{
|
||||||
|
if (InvokeRequired)
|
||||||
|
{
|
||||||
|
Invoke(RefreshData);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var entries = _activityLog.GetRecent(200);
|
||||||
|
_grid.Rows.Clear();
|
||||||
|
|
||||||
|
foreach (var entry in entries)
|
||||||
|
{
|
||||||
|
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 ?? ""
|
||||||
|
);
|
||||||
|
|
||||||
|
var row = _grid.Rows[rowIndex];
|
||||||
|
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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void OnFormClosing(FormClosingEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.CloseReason == CloseReason.UserClosing)
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
Hide();
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
base.OnFormClosing(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
111
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
111
src/SpamGuard/Tray/TrayApplicationContext.cs
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
// src/SpamGuard/Tray/TrayApplicationContext.cs
|
||||||
|
namespace SpamGuard.Tray;
|
||||||
|
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using Microsoft.Extensions.Hosting;
|
||||||
|
using SpamGuard.Services;
|
||||||
|
|
||||||
|
public sealed class TrayApplicationContext : ApplicationContext
|
||||||
|
{
|
||||||
|
private readonly NotifyIcon _notifyIcon;
|
||||||
|
private readonly System.Windows.Forms.Timer _refreshTimer;
|
||||||
|
private readonly ActivityLog _activityLog;
|
||||||
|
private readonly InboxMonitorService _monitor;
|
||||||
|
private readonly IHost _host;
|
||||||
|
private ActivityLogForm? _logForm;
|
||||||
|
private readonly ToolStripMenuItem _pauseMenuItem;
|
||||||
|
|
||||||
|
public TrayApplicationContext(IHost host)
|
||||||
|
{
|
||||||
|
_host = host;
|
||||||
|
_activityLog = host.Services.GetRequiredService<ActivityLog>();
|
||||||
|
_monitor = host.Services.GetRequiredService<InboxMonitorService>();
|
||||||
|
|
||||||
|
_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;
|
||||||
|
|
||||||
|
_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;
|
||||||
|
|
||||||
|
// Update icon based on state
|
||||||
|
if (!_monitor.IsPaused)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logForm?.RefreshData();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnViewLog(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_logForm == null || _logForm.IsDisposed)
|
||||||
|
{
|
||||||
|
_logForm = new ActivityLogForm(_activityLog);
|
||||||
|
}
|
||||||
|
_logForm.Show();
|
||||||
|
_logForm.BringToFront();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnPauseResume(object? sender, EventArgs e)
|
||||||
|
{
|
||||||
|
if (_monitor.IsPaused)
|
||||||
|
{
|
||||||
|
_monitor.Resume();
|
||||||
|
_pauseMenuItem.Text = "Pause";
|
||||||
|
_notifyIcon.Icon = IconGenerator.Green;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_monitor.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);
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
100
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
100
tests/SpamGuard.Tests/Services/EmailClassifierTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
// 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;
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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()
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
namespace SpamGuard.Tests;
|
|
||||||
|
|
||||||
public class UnitTest1
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void Test1()
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user