Fix spam folder detection — search two levels deep, try common names

This commit is contained in:
2026-04-12 09:50:24 +01:00
parent 27c6d12183
commit b5f8b7300b

View File

@@ -5,7 +5,6 @@ using MailKit;
using MailKit.Search; using MailKit.Search;
using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using MimeKit; using MimeKit;
using SpamGuard.Configuration; using SpamGuard.Configuration;
using SpamGuard.Models; using SpamGuard.Models;
@@ -15,15 +14,18 @@ public sealed partial class InboxMonitorService : BackgroundService
{ {
private readonly ImapClientFactory _imapFactory; private readonly ImapClientFactory _imapFactory;
private readonly TrustedSenderStore _trustedSenders; private readonly TrustedSenderStore _trustedSenders;
private readonly BlockedDomainStore _blockedDomains;
private readonly ProcessedUidStore _processedUids; private readonly ProcessedUidStore _processedUids;
private readonly EmailClassifier _classifier; private readonly EmailClassifier _classifier;
private readonly ActivityLog _activityLog; private readonly ActivityLog _activityLog;
private readonly SpamGuardOptions _options; private readonly ImapOptions _imap;
private readonly MonitoringOptions _monitoring;
private readonly ILogger<InboxMonitorService> _logger; private readonly ILogger<InboxMonitorService> _logger;
private volatile bool _paused; private volatile bool _paused;
private uint _lastSeenUid; private uint _lastSeenUid;
public string AccountName => _imap.Name.Length > 0 ? _imap.Name : _imap.Username;
public bool IsPaused => _paused; public bool IsPaused => _paused;
public void Pause() => _paused = true; public void Pause() => _paused = true;
public void Resume() => _paused = false; public void Resume() => _paused = false;
@@ -31,24 +33,28 @@ public sealed partial class InboxMonitorService : BackgroundService
public InboxMonitorService( public InboxMonitorService(
ImapClientFactory imapFactory, ImapClientFactory imapFactory,
TrustedSenderStore trustedSenders, TrustedSenderStore trustedSenders,
BlockedDomainStore blockedDomains,
ProcessedUidStore processedUids, ProcessedUidStore processedUids,
EmailClassifier classifier, EmailClassifier classifier,
ActivityLog activityLog, ActivityLog activityLog,
IOptions<SpamGuardOptions> options, ImapOptions imap,
MonitoringOptions monitoring,
ILogger<InboxMonitorService> logger) ILogger<InboxMonitorService> logger)
{ {
_imapFactory = imapFactory; _imapFactory = imapFactory;
_trustedSenders = trustedSenders; _trustedSenders = trustedSenders;
_blockedDomains = blockedDomains;
_processedUids = processedUids; _processedUids = processedUids;
_classifier = classifier; _classifier = classifier;
_activityLog = activityLog; _activityLog = activityLog;
_options = options.Value; _imap = imap;
_monitoring = monitoring;
_logger = logger; _logger = logger;
} }
protected override async Task ExecuteAsync(CancellationToken stoppingToken) 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 // Brief delay to let TrustedSenderService do its first scan
await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken); await Task.Delay(TimeSpan.FromSeconds(10), stoppingToken);
@@ -70,18 +76,21 @@ public sealed partial class InboxMonitorService : BackgroundService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error polling inbox"); _logger.LogError(ex, "Error polling inbox");
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, "", "", Verdict.Error, null, ex.Message)); {
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) 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; var inbox = client.Inbox;
await inbox.OpenAsync(FolderAccess.ReadWrite, ct); await inbox.OpenAsync(FolderAccess.ReadWrite, ct);
@@ -102,7 +111,7 @@ public sealed partial class InboxMonitorService : BackgroundService
{ {
// First ever run -- initial scan window // First ever run -- initial scan window
uids = await inbox.SearchAsync( 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); _logger.LogDebug("Found {Count} messages in inbox", uids.Count);
@@ -124,8 +133,12 @@ public sealed partial class InboxMonitorService : BackgroundService
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error processing UID={Uid}", uid.Id); _logger.LogError(ex, "Error processing UID={Uid}", uid.Id);
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, "", $"UID {uid.Id}", Verdict.Error, null, ex.Message)); {
Timestamp = DateTime.UtcNow, Sender = "", Subject = $"UID {uid.Id}",
Verdict = Verdict.Error, Reason = ex.Message,
Uid = uid.Id, AccountName = AccountName
});
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
} }
@@ -141,19 +154,41 @@ public sealed partial class InboxMonitorService : BackgroundService
var message = await inbox.GetMessageAsync(uid, ct); var message = await inbox.GetMessageAsync(uid, ct);
var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown"; var from = message.From.Mailboxes.FirstOrDefault()?.Address ?? "unknown";
var subject = message.Subject ?? "(no subject)"; var subject = message.Subject ?? "(no subject)";
var bodySnippet = ExtractBodySnippet(message);
// Check trusted senders // Check trusted senders
if (_trustedSenders.IsTrusted(from)) if (_trustedSenders.IsTrusted(from))
{ {
_logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from); _logger.LogDebug("UID={Uid} from trusted sender {From}, skipping", uid.Id, from);
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Trusted, null, null)); {
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); _processedUids.Add(uid.Id);
return; return;
} }
// Extract body snippet
var bodySnippet = ExtractBodySnippet(message);
var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date); var emailSummary = new EmailSummary(uid.Id, from, subject, bodySnippet, message.Date);
// Classify // Classify
@@ -161,15 +196,18 @@ public sealed partial class InboxMonitorService : BackgroundService
if (result == null) if (result == null)
{ {
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Error, null, "Classification failed")); {
Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Error, Reason = "Classification failed",
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
_processedUids.Add(uid.Id); _processedUids.Add(uid.Id);
return; return;
} }
if (result.IsSpam && result.Confidence >= _options.Monitoring.SpamConfidenceThreshold) if (result.IsSpam && result.Confidence >= _monitoring.SpamConfidenceThreshold)
{ {
// Move to spam folder
if (spamFolder != null) if (spamFolder != null)
{ {
await inbox.MoveToAsync(uid, spamFolder, ct); 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); await inbox.AddFlagsAsync(uid, MailKit.MessageFlags.Flagged, true, ct);
} }
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Spam, result.Confidence, result.Reason)); {
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) else if (result.IsSpam)
{ {
// Below threshold -- uncertain _activityLog.Add(new ActivityEntry
_activityLog.Add(new ActivityEntry( {
DateTime.UtcNow, from, subject, Verdict.Uncertain, result.Confidence, result.Reason)); Timestamp = DateTime.UtcNow, Sender = from, Subject = subject,
Verdict = Verdict.Uncertain, Confidence = result.Confidence, Reason = result.Reason,
Uid = uid.Id, AccountName = AccountName, BodySnippet = bodySnippet
});
} }
else else
{ {
_activityLog.Add(new ActivityEntry( _activityLog.Add(new ActivityEntry
DateTime.UtcNow, from, subject, Verdict.Legitimate, result.Confidence, result.Reason)); {
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); _processedUids.Add(uid.Id);
@@ -225,32 +274,102 @@ public sealed partial class InboxMonitorService : BackgroundService
[System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")] [System.Text.RegularExpressions.GeneratedRegex(@"\s{2,}")]
private static partial System.Text.RegularExpressions.Regex CollapseWhitespace(); 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) private async Task<IMailFolder?> 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 try
{ {
var junk = client.GetFolder(MailKit.SpecialFolder.Junk); 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) 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 // 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 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; }
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 try
{ {
var personal = client.GetFolder(client.PersonalNamespaces[0]); var sub = await folder.GetSubfoldersAsync(false, ct);
var folders = await personal.GetSubfoldersAsync(false, ct); var match = sub.FirstOrDefault(f => candidates.Contains(f.Name));
return folders.FirstOrDefault(f => if (match != null)
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); _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; return null;
} }
} }