diff --git a/src/SpamGuard/Tray/ActivityLogForm.cs b/src/SpamGuard/Tray/ActivityLogForm.cs index 2ae2536..85d2643 100644 --- a/src/SpamGuard/Tray/ActivityLogForm.cs +++ b/src/SpamGuard/Tray/ActivityLogForm.cs @@ -2,21 +2,98 @@ namespace SpamGuard.Tray; using SpamGuard.Models; using SpamGuard.Services; +using SpamGuard.State; public sealed class ActivityLogForm : Form { private readonly ActivityLog _activityLog; + private readonly IReadOnlyList _monitors; + private readonly TrustedSenderStore _trustedSenders; + private readonly BlockedDomainStore _blockedDomains; + private readonly OverrideStore _overrideStore; private readonly DataGridView _grid; + private readonly ToolStrip _toolbar; + private readonly ContextMenuStrip _rowMenu; - public ActivityLogForm(ActivityLog activityLog) + private const int DisplayCount = 200; + + public ActivityLogForm( + ActivityLog activityLog, + IReadOnlyList monitors, + TrustedSenderStore trustedSenders, + BlockedDomainStore blockedDomains, + OverrideStore overrideStore) { _activityLog = activityLog; + _monitors = monitors; + _trustedSenders = trustedSenders; + _blockedDomains = blockedDomains; + _overrideStore = overrideStore; Text = "SpamGuard - Activity Log"; - Size = new System.Drawing.Size(800, 500); + Size = new System.Drawing.Size(900, 550); StartPosition = FormStartPosition.CenterScreen; Icon = IconGenerator.Green; + KeyPreview = true; + // Toolbar + _toolbar = new ToolStrip(); + var btnMarkSpam = new ToolStripButton("Mark Spam (S)") + { + ForeColor = System.Drawing.Color.Red, + Tag = "spam" + }; + btnMarkSpam.Click += OnMarkSpam; + + var btnMarkNotSpam = new ToolStripButton("Not Spam (H)") + { + ForeColor = System.Drawing.Color.Green, + Tag = "ham" + }; + btnMarkNotSpam.Click += OnMarkNotSpam; + + var btnBlockDomain = new ToolStripButton("Block Domain (D)") + { + ForeColor = System.Drawing.Color.DarkRed, + Tag = "block" + }; + btnBlockDomain.Click += OnBlockDomain; + + var btnAllowDomain = new ToolStripButton("Allow Domain") + { + ForeColor = System.Drawing.Color.DarkGreen, + Tag = "allow" + }; + btnAllowDomain.Click += OnAllowDomain; + + _toolbar.Items.AddRange(new ToolStripItem[] + { + btnMarkSpam, + btnMarkNotSpam, + new ToolStripSeparator(), + btnBlockDomain, + btnAllowDomain + }); + + // Context menu + _rowMenu = new ContextMenuStrip(); + var ctxSpam = new ToolStripMenuItem("Mark as Spam (S)"); + ctxSpam.Click += OnMarkSpam; + var ctxNotSpam = new ToolStripMenuItem("Mark as Not Spam (H)"); + ctxNotSpam.Click += OnMarkNotSpam; + var ctxBlockDomain = new ToolStripMenuItem("Always Block Domain (D)"); + ctxBlockDomain.Click += OnBlockDomain; + var ctxAllowDomain = new ToolStripMenuItem("Never Block Domain"); + ctxAllowDomain.Click += OnAllowDomain; + + _rowMenu.Items.AddRange(new ToolStripItem[] + { + ctxSpam, ctxNotSpam, + new ToolStripSeparator(), + ctxBlockDomain, ctxAllowDomain + }); + + // Grid _grid = new DataGridView { Dock = DockStyle.Fill, @@ -26,7 +103,8 @@ public sealed class ActivityLogForm : Form AutoSizeColumnsMode = DataGridViewAutoSizeColumnsMode.Fill, SelectionMode = DataGridViewSelectionMode.FullRowSelect, BackgroundColor = System.Drawing.SystemColors.Window, - BorderStyle = BorderStyle.None + BorderStyle = BorderStyle.None, + ContextMenuStrip = _rowMenu }; _grid.Columns.Add("Time", "Time"); @@ -35,17 +113,142 @@ public sealed class ActivityLogForm : Form _grid.Columns.Add("Verdict", "Verdict"); _grid.Columns.Add("Confidence", "Confidence"); _grid.Columns.Add("Reason", "Reason"); + _grid.Columns.Add("Status", ""); _grid.Columns["Time"]!.Width = 70; _grid.Columns["From"]!.Width = 150; _grid.Columns["Verdict"]!.Width = 80; _grid.Columns["Confidence"]!.Width = 80; + _grid.Columns["Status"]!.Width = 30; + _grid.Columns["Status"]!.AutoSizeMode = DataGridViewAutoSizeColumnMode.None; Controls.Add(_grid); + Controls.Add(_toolbar); RefreshData(); } + protected override bool ProcessCmdKey(ref Message msg, Keys keyData) + { + if (_grid.SelectedRows.Count == 0) + return base.ProcessCmdKey(ref msg, keyData); + + switch (keyData) + { + case Keys.S: + OnMarkSpam(this, EventArgs.Empty); + return true; + case Keys.H: + OnMarkNotSpam(this, EventArgs.Empty); + return true; + case Keys.D: + OnBlockDomain(this, EventArgs.Empty); + return true; + default: + return base.ProcessCmdKey(ref msg, keyData); + } + } + + private async void OnMarkSpam(object? sender, EventArgs e) + { + var entry = GetSelectedEntry(); + if (entry == null || entry.Uid == 0) return; + + var monitor = FindMonitor(entry.AccountName); + if (monitor == null) return; + + try + { + await monitor.MoveToSpamAsync(entry.Uid); + RecordOverride(entry, "spam"); + _activityLog.UpdateEntry(entry, Verdict.Spam, UserOverride.MarkedSpam); + RefreshData(); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to move email: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private async void OnMarkNotSpam(object? sender, EventArgs e) + { + var entry = GetSelectedEntry(); + if (entry == null || entry.Uid == 0) return; + + var monitor = FindMonitor(entry.AccountName); + if (monitor == null) return; + + try + { + await monitor.MoveToInboxAsync(entry.Uid); + _trustedSenders.Add(entry.Sender); + _trustedSenders.Save(); + RecordOverride(entry, "legitimate"); + _activityLog.UpdateEntry(entry, Verdict.Legitimate, UserOverride.MarkedLegitimate); + RefreshData(); + } + catch (Exception ex) + { + MessageBox.Show($"Failed to move email: {ex.Message}", "Error", + MessageBoxButtons.OK, MessageBoxIcon.Error); + } + } + + private void OnBlockDomain(object? sender, EventArgs e) + { + var entry = GetSelectedEntry(); + if (entry == null || string.IsNullOrEmpty(entry.Sender)) return; + + var domain = BlockedDomainStore.ExtractDomain(entry.Sender); + if (string.IsNullOrEmpty(domain)) return; + + _blockedDomains.Add(domain); + _blockedDomains.Save(); + + OnMarkSpam(sender, e); + } + + private void OnAllowDomain(object? sender, EventArgs e) + { + var entry = GetSelectedEntry(); + if (entry == null || string.IsNullOrEmpty(entry.Sender)) return; + + var domain = BlockedDomainStore.ExtractDomain(entry.Sender); + if (string.IsNullOrEmpty(domain)) return; + + _blockedDomains.Remove(domain); + _blockedDomains.Save(); + + OnMarkNotSpam(sender, e); + } + + private ActivityEntry? GetSelectedEntry() + { + if (_grid.SelectedRows.Count == 0) return null; + return _grid.SelectedRows[0].Tag as ActivityEntry; + } + + private InboxMonitorService? FindMonitor(string accountName) + { + return _monitors.FirstOrDefault(m => + m.AccountName.Equals(accountName, StringComparison.OrdinalIgnoreCase)); + } + + private void RecordOverride(ActivityEntry entry, string userVerdict) + { + var originalVerdict = entry.Verdict.ToString().ToLowerInvariant(); + _overrideStore.Add(new OverrideRecord( + DateTime.UtcNow, + entry.Sender, + entry.Subject, + entry.BodySnippet, + userVerdict, + originalVerdict + )); + _overrideStore.Save(); + } + public void RefreshData() { if (InvokeRequired) @@ -54,21 +257,41 @@ public sealed class ActivityLogForm : Form return; } - var entries = _activityLog.GetRecent(200); + // Preserve selection and scroll position across refresh + var selectedEntry = GetSelectedEntry(); + var selectedUid = selectedEntry?.Uid ?? 0; + var selectedTimestamp = selectedEntry?.Timestamp ?? default; + var firstVisible = _grid.FirstDisplayedScrollingRowIndex; + + var entries = _activityLog.GetRecent(DisplayCount); + + _grid.SuspendLayout(); _grid.Rows.Clear(); + int restoreIndex = -1; + foreach (var entry in entries) { + var overrideMarker = entry.Override switch + { + UserOverride.MarkedSpam => "\u2716", // heavy X + UserOverride.MarkedLegitimate => "\u2714", // heavy checkmark + _ => "" + }; + var rowIndex = _grid.Rows.Add( entry.Timestamp.ToLocalTime().ToString("HH:mm:ss"), entry.Sender, entry.Subject, entry.Verdict.ToString(), entry.Confidence?.ToString("P0") ?? "--", - entry.Reason ?? "" + entry.Reason ?? "", + overrideMarker ); var row = _grid.Rows[rowIndex]; + row.Tag = entry; // attach entry for reliable retrieval + row.DefaultCellStyle.ForeColor = entry.Verdict switch { Verdict.Spam => System.Drawing.Color.Red, @@ -77,7 +300,37 @@ public sealed class ActivityLogForm : Form Verdict.Error => System.Drawing.Color.Gray, _ => System.Drawing.SystemColors.ControlText }; + + if (entry.Override != null) + { + row.DefaultCellStyle.Font = new System.Drawing.Font( + _grid.DefaultCellStyle.Font ?? System.Drawing.SystemFonts.DefaultFont, + System.Drawing.FontStyle.Bold); + } + + // Match by Uid (preferred) or timestamp fallback for Uid==0 entries + if (restoreIndex < 0) + { + if (selectedUid != 0 && entry.Uid == selectedUid) + restoreIndex = rowIndex; + else if (selectedUid == 0 && entry.Timestamp == selectedTimestamp) + restoreIndex = rowIndex; + } } + + // Restore scroll position + if (_grid.RowCount > 0 && firstVisible >= 0) + { + var clampedFirst = Math.Min(firstVisible, _grid.RowCount - 1); + _grid.FirstDisplayedScrollingRowIndex = clampedFirst; + } + + // Restore selection without triggering a scroll jump + _grid.ClearSelection(); + if (restoreIndex >= 0) + _grid.Rows[restoreIndex].Selected = true; + + _grid.ResumeLayout(); } protected override void OnFormClosing(FormClosingEventArgs e) diff --git a/tests/SpamGuard.Tests/Services/EmailClassifierTests.cs b/tests/SpamGuard.Tests/Services/EmailClassifierTests.cs index 0268b18..aa290c5 100644 --- a/tests/SpamGuard.Tests/Services/EmailClassifierTests.cs +++ b/tests/SpamGuard.Tests/Services/EmailClassifierTests.cs @@ -8,6 +8,7 @@ using Microsoft.Extensions.Options; using SpamGuard.Configuration; using SpamGuard.Models; using SpamGuard.Services; +using SpamGuard.State; public class EmailClassifierTests { @@ -35,7 +36,8 @@ public class EmailClassifierTests var classifier = new EmailClassifier( Options.Create(DefaultOptions), new NullLogger(), - new HttpClient() + new HttpClient(), + new OverrideStore(Path.GetTempPath()) ); var prompt = classifier.BuildPrompt(SampleEmail); @@ -54,7 +56,8 @@ public class EmailClassifierTests var classifier = new EmailClassifier( Options.Create(DefaultOptions), new NullLogger(), - new HttpClient() + new HttpClient(), + new OverrideStore(Path.GetTempPath()) ); var prompt = classifier.BuildPrompt(email);