From b5f8b7300b9d64bc97a7125d6320fa9d708b6cf1 Mon Sep 17 00:00:00 2001 From: Peter Date: Sun, 12 Apr 2026 09:50:24 +0100 Subject: [PATCH] =?UTF-8?q?Fix=20spam=20folder=20detection=20=E2=80=94=20s?= =?UTF-8?q?earch=20two=20levels=20deep,=20try=20common=20names?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/SpamGuard/Services/InboxMonitorService.cs | 197 ++++++++++++++---- 1 file changed, 158 insertions(+), 39 deletions(-) diff --git a/src/SpamGuard/Services/InboxMonitorService.cs b/src/SpamGuard/Services/InboxMonitorService.cs index c7c2017..bf533ed 100644 --- a/src/SpamGuard/Services/InboxMonitorService.cs +++ b/src/SpamGuard/Services/InboxMonitorService.cs @@ -5,7 +5,6 @@ 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; @@ -15,15 +14,18 @@ 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 SpamGuardOptions _options; + private readonly ImapOptions _imap; + private readonly MonitoringOptions _monitoring; private readonly ILogger _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; @@ -31,24 +33,28 @@ public sealed partial class InboxMonitorService : BackgroundService public InboxMonitorService( ImapClientFactory imapFactory, TrustedSenderStore trustedSenders, + BlockedDomainStore blockedDomains, ProcessedUidStore processedUids, EmailClassifier classifier, ActivityLog activityLog, - IOptions options, + ImapOptions imap, + MonitoringOptions monitoring, ILogger logger) { _imapFactory = imapFactory; _trustedSenders = trustedSenders; + _blockedDomains = blockedDomains; _processedUids = processedUids; _classifier = classifier; _activityLog = activityLog; - _options = options.Value; + _imap = imap; + _monitoring = monitoring; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - _logger.LogInformation("InboxMonitorService started"); + _logger.LogInformation("InboxMonitorService started for {Account}", AccountName); // Brief delay to let TrustedSenderService do its first scan await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); @@ -70,18 +76,21 @@ public sealed partial class InboxMonitorService : BackgroundService catch (Exception ex) { _logger.LogError(ex, "Error polling inbox"); - _activityLog.Add(new ActivityEntry( - DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message)); + _activityLog.Add(new ActivityEntry + { + Timestamp = DateTime.UtcNow, Sender = "", Subject = "", + Verdict = Verdict.Error, Reason = ex.Message, AccountName = AccountName + }); } } - await Task.Delay(TimeSpan.FromSeconds(_options.Monitoring.PollIntervalSeconds), stoppingToken); + await Task.Delay(TimeSpan.FromSeconds(_monitoring.PollIntervalSeconds), stoppingToken); } } private async Task PollInboxAsync(CancellationToken ct) { - using var client = await _imapFactory.CreateConnectedClientAsync(ct); + using var client = await _imapFactory.CreateConnectedClientAsync(_imap, ct); var inbox = client.Inbox; await inbox.OpenAsync(FolderAccess.ReadWrite, ct); @@ -102,7 +111,7 @@ public sealed partial class InboxMonitorService : BackgroundService { // First ever run -- initial scan window uids = await inbox.SearchAsync( - SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_options.Monitoring.InitialScanDays)), ct); + SearchQuery.DeliveredAfter(DateTime.UtcNow.AddDays(-_monitoring.InitialScanDays)), ct); } _logger.LogDebug("Found {Count} messages in inbox", uids.Count); @@ -124,8 +133,12 @@ public sealed partial class InboxMonitorService : BackgroundService 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)); + _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); } @@ -141,19 +154,41 @@ public sealed partial class InboxMonitorService : BackgroundService 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( - DateTime.UtcNow, from, subject, Verdict.Trusted, null, null)); + _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; } - // Extract body snippet - var bodySnippet = ExtractBodySnippet(message); var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date); // Classify @@ -161,15 +196,18 @@ public sealed partial class InboxMonitorService : BackgroundService if (result == null) { - _activityLog.Add(new ActivityEntry( - DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed")); + _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 >= _options.Monitoring.SpamConfidenceThreshold) + if (result.IsSpam && result.Confidence >= _monitoring.SpamConfidenceThreshold) { - // Move to spam folder if (spamFolder != null) { await inbox.MoveToAsync(uid, spamFolder, ct); @@ -181,19 +219,30 @@ public sealed partial class InboxMonitorService : BackgroundService await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct); } - _activityLog.Add(new ActivityEntry( - DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason)); + _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) { - // Below threshold -- uncertain - _activityLog.Add(new ActivityEntry( - DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason)); + _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( - DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason)); + _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); @@ -225,32 +274,102 @@ public sealed partial class InboxMonitorService : BackgroundService [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 FindSpamFolderAsync(MailKit.Net.Imap.ImapClient client, CancellationToken ct) { - // Try special folder first + // 1. Try IMAP special-use \Junk attribute (most reliable when server advertises it) try { var junk = client.GetFolder(MailKit.SpecialFolder.Junk); - if (junk != null) return junk; + if (junk != null) + { + _logger.LogDebug("Found spam folder via special-use attribute: {Full}", junk.FullName); + return junk; + } } catch (Exception ex) { - _logger.LogDebug(ex, "Could not get Junk special folder"); + _logger.LogDebug(ex, "Server does not advertise Junk special-use attribute"); } - // Fall back to configured folder name - try + // 2. Search by name — try configured name plus common variants, two levels deep + var candidates = new HashSet(StringComparer.OrdinalIgnoreCase) { - 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) + _monitoring.SpamFolderName, "Junk", "Spam", "Junk E-mail", "Junk Mail", "Bulk Mail" + }; + + foreach (var ns in client.PersonalNamespaces) { - _logger.LogDebug(ex, "Could not find spam folder by name '{FolderName}'", _options.Monitoring.SpamFolderName); + IMailFolder root; + try { root = client.GetFolder(ns); } + catch { continue; } + + IList topLevel; + try { topLevel = await root.GetSubfoldersAsync(false, ct); } + catch (Exception ex) { _logger.LogDebug(ex, "Could not list top-level folders"); continue; } + + 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); + 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; } }