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 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user