Fix spam folder detection — search two levels deep, try common names
This commit is contained in:
@@ -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<InboxMonitorService> _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<SpamGuardOptions> options,
|
||||
ImapOptions imap,
|
||||
MonitoringOptions monitoring,
|
||||
ILogger<InboxMonitorService> 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<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
|
||||
{
|
||||
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<string>(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<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
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user