feat: add InboxMonitorService with classification pipeline and spam moving
This commit is contained in:
216
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
216
src/SpamGuard/Services/InboxMonitorService.cs
Normal file
@@ -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<InboxMonitorService> _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<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: 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<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 { }
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user