From 3401cfce34119974c1f32c3847a69641dc552847 Mon Sep 17 00:00:00 2001 From: peter Date: Tue, 7 Apr 2026 11:44:14 +0100 Subject: [PATCH] feat: add InboxMonitorService with classification pipeline and spam moving --- src/SpamGuard/Services/InboxMonitorService.cs | 216 ++++++++++++++++++ 1 file changed, 216 insertions(+) create mode 100644 src/SpamGuard/Services/InboxMonitorService.cs diff --git a/src/SpamGuard/Services/InboxMonitorService.cs b/src/SpamGuard/Services/InboxMonitorService.cs new file mode 100644 index 0000000..36e734d --- /dev/null +++ b/src/SpamGuard/Services/InboxMonitorService.cs @@ -0,0 +1,216 @@ +// 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 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 _logger; + + private volatile bool _paused; + + 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 options, + ILogger 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: recent messages only + var since = _processedUids.Count == 0 + ? SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)) + : SearchQuery.All; + + var uids = await inbox.SearchAsync(since, 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)) + 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); // Skip on next run to avoid infinite retry + } + } + + 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 ?? message.HtmlBody ?? ""; + + // Strip HTML tags if we fell back to HTML body + if (message.TextBody == null && message.HtmlBody != null) + text = System.Text.RegularExpressions.Regex.Replace(text, "<[^>]+>", " "); + + return text.Length > 2000 ? text[..2000] : text; + } + + private async Task 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 { } + + // 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 { } + + return null; + } +}