diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 909777a..915e771 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -135,6 +135,7 @@ function folderToRequest(slug, page, pageSize) { export const EmailApi = { get: (id) => api.get(`/email/${id}`).then((r) => r.data), + summary: (id) => api.get(`/email/${id}/summary`).then((r) => r.data), markRead: (id) => api.post(`/email/${id}/read`), markUnread: (id) => api.post(`/email/${id}/unread`), star: (id) => api.post(`/email/${id}/star`), diff --git a/frontend/src/pages/Senders.jsx b/frontend/src/pages/Senders.jsx index 98101f1..a10f44f 100644 --- a/frontend/src/pages/Senders.jsx +++ b/frontend/src/pages/Senders.jsx @@ -71,10 +71,13 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) { function EmailDetail({ email: summary, onBack }) { const [detail, setDetail] = useState(null); const [loading, setLoading] = useState(true); + const [aiSummary, setAiSummary] = useState(null); + const [aiLoading, setAiLoading] = useState(false); useEffect(() => { setDetail(null); setLoading(true); + setAiSummary(null); EmailApi.get(summary.id) .then(setDetail) .catch(() => setDetail(null)) @@ -83,6 +86,14 @@ function EmailDetail({ email: summary, onBack }) { const email = detail ?? summary; + const fetchAiSummary = () => { + setAiLoading(true); + EmailApi.summary(summary.id) + .then((r) => setAiSummary(r.summary)) + .catch(() => setAiSummary(null)) + .finally(() => setAiLoading(false)); + }; + return (
@@ -101,6 +112,18 @@ function EmailDetail({ email: summary, onBack }) {
+ {!loading && ( +
+ {aiSummary != null ? ( +
✨ {aiSummary}
+ ) : ( + + )} +
+ )} + {loading &&
Loading message…
} {!loading && detail?.bodyText && ( diff --git a/frontend/src/pages/Unsubscribe.jsx b/frontend/src/pages/Unsubscribe.jsx index 1e5d2e5..8084980 100644 --- a/frontend/src/pages/Unsubscribe.jsx +++ b/frontend/src/pages/Unsubscribe.jsx @@ -25,7 +25,7 @@ export default function Unsubscribe() { const visible = useMemo(() => { const f = FILTERS.find((f) => f.key === filter); const list = !f?.statuses ? items : items.filter((i) => f.statuses.includes(i.status)); - return [...list].sort((a, b) => b.emailCount - a.emailCount); + return [...list].sort((a, b) => b.confidence - a.confidence || b.emailCount - a.emailCount); }, [items, filter]); const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]); @@ -97,6 +97,7 @@ export default function Unsubscribe() { Domain Method Emails + Confidence Status @@ -108,11 +109,16 @@ export default function Unsubscribe() { {it.domain} {METHOD[it.method]} {it.emailCount.toLocaleString()} + + = 0.7 ? 'high' : it.confidence >= 0.4 ? 'mid' : 'low'}`}> + {Math.round(it.confidence * 100)}% + + {STATUS[it.status]} ))} {!visible.length && ( - + {items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'} )} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 1402b43..ea59c5f 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -233,6 +233,8 @@ table.grid th, table.grid td { padding: 8px; border-bottom: 1px solid #2c3550; t .sd-detail-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; } .sd-detail-sep { opacity: 0.4; } .sd-snippet { background: var(--panel); border: 1px solid #2c3550; border-radius: 8px; padding: 14px 16px; font-size: 13px; line-height: 1.6; color: var(--muted); white-space: pre-wrap; margin-bottom: 18px; } +.sd-ai-summary { margin-bottom: 14px; } +.sd-ai-summary-text { background: var(--panel-2); border: 1px solid #2c3550; border-radius: 8px; padding: 10px 14px; font-size: 13px; color: var(--text); } .sd-detail-actions { display: flex; gap: 10px; } .btn-sm { background: var(--accent); color: #fff; border: none; border-radius: 6px; @@ -382,6 +384,10 @@ input, select { background: var(--panel-2); border: 1px solid #2c3550; color: va .unsub-badge--ok { color: var(--ok); border-color: var(--ok); } .unsub-badge--fail { color: var(--danger); border-color: var(--danger); } .unsub-badge--skip { color: var(--muted); } +.unsub-confidence { font-size: 11px; font-weight: 600; } +.unsub-confidence--high { color: var(--ok); } +.unsub-confidence--mid { color: #f2c94c; } +.unsub-confidence--low { color: var(--danger); } /* ── Bulk selection toolbar (folder/search/sender email lists) ──────────── */ .bulk-toolbar { diff --git a/src/InboxIntel.Api/Controllers/EmailController.cs b/src/InboxIntel.Api/Controllers/EmailController.cs index 42cb4e0..94d52e2 100644 --- a/src/InboxIntel.Api/Controllers/EmailController.cs +++ b/src/InboxIntel.Api/Controllers/EmailController.cs @@ -18,12 +18,14 @@ public class EmailController : ApiControllerBase { private readonly ICleanupService _cleanup; private readonly IUnsubscribeService _unsub; + private readonly IAiService _ai; private readonly AppDbContext _db; - public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, AppDbContext db) + public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, IAiService ai, AppDbContext db) { _cleanup = cleanup; _unsub = unsub; + _ai = ai; _db = db; } @@ -42,6 +44,16 @@ public class EmailController : ApiControllerBase return e is null ? NotFound() : Ok(e); } + /// One-line AI summary, fetched on demand (returns the snippet verbatim if AI is disabled). + [HttpGet("{id:guid}/summary")] + public async Task Summary(Guid id, CancellationToken ct) + { + var e = await _db.Emails.FirstOrDefaultAsync(e => e.Id == id && e.UserId == UserId, ct); + if (e is null) return NotFound(); + var summary = await _ai.SummarizeEmailAsync(e.Subject, e.Snippet, e.BodyText, ct); + return Ok(new { summary }); + } + [HttpPost("{id:guid}/read")] public Task MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct); diff --git a/src/InboxIntel.Application/Abstractions/IServices.cs b/src/InboxIntel.Application/Abstractions/IServices.cs index 658e9cb..2f77901 100644 --- a/src/InboxIntel.Application/Abstractions/IServices.cs +++ b/src/InboxIntel.Application/Abstractions/IServices.cs @@ -55,6 +55,21 @@ public interface IAiService { bool IsEnabled { get; } Task ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default); + /// + /// Lightweight classification used as a sync-time fallback when the + /// heuristic rule set doesn't match anything specific. Takes raw fields + /// directly (no DB lookup) since the email entity may not be persisted + /// yet during sync. + /// + Task ClassifyFallbackAsync(string? subject, string senderAddress, string? snippet, CancellationToken ct = default); + /// + /// AI opinion (0-1) on how safe it is to unsubscribe from a sender, given + /// the sender address and a recent subject line. Returns 0.5 (neutral) if + /// AI is disabled or the call fails - callers blend this with a heuristic. + /// + Task ScoreUnsubscribeConfidenceAsync(string senderAddress, string? recentSubject, int emailCount, CancellationToken ct = default); + /// One-line plain-language summary of a single email, for list/preview UIs. + Task SummarizeEmailAsync(string? subject, string? snippet, string? bodyText, CancellationToken ct = default); Task SummarizeInboxAsync(Guid userId, CancellationToken ct = default); Task> SuggestCleanupAsync(Guid userId, CancellationToken ct = default); Task GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default); diff --git a/src/InboxIntel.Application/DTOs/CleanupDtos.cs b/src/InboxIntel.Application/DTOs/CleanupDtos.cs index 5814fbb..fa20f8f 100644 --- a/src/InboxIntel.Application/DTOs/CleanupDtos.cs +++ b/src/InboxIntel.Application/DTOs/CleanupDtos.cs @@ -33,6 +33,7 @@ public record UnsubscribeItemDto( UnsubscribeMethod Method, UnsubscribeStatus Status, int EmailCount, - string? UnsubscribeTarget); + string? UnsubscribeTarget, + double Confidence); public record UnsubscribeRequestDto(IReadOnlyList ItemIds, bool Confirmed); diff --git a/src/InboxIntel.Domain/Entities/UnsubscribeItem.cs b/src/InboxIntel.Domain/Entities/UnsubscribeItem.cs index 25e2e46..b90ace2 100644 --- a/src/InboxIntel.Domain/Entities/UnsubscribeItem.cs +++ b/src/InboxIntel.Domain/Entities/UnsubscribeItem.cs @@ -26,4 +26,11 @@ public class UnsubscribeItem : AuditableEntity public DateTimeOffset? LastAttemptUtc { get; set; } public string? ResultMessage { get; set; } + + /// + /// 0-1 confidence that unsubscribing is safe (won't cut off something the + /// user actually wants, e.g. security alerts or account notices). Combines + /// a method/volume heuristic with an AI opinion when AI is enabled. + /// + public double Confidence { get; set; } } diff --git a/src/InboxIntel.Infrastructure/Ai/AiService.cs b/src/InboxIntel.Infrastructure/Ai/AiService.cs index b76ed6d..7b962ab 100644 --- a/src/InboxIntel.Infrastructure/Ai/AiService.cs +++ b/src/InboxIntel.Infrastructure/Ai/AiService.cs @@ -39,6 +39,68 @@ public class AiService : IAiService return new AiClassificationDto(emailId, category, raw.Length > 0 ? 0.8 : 0); } + public async Task ClassifyFallbackAsync(string? subject, string senderAddress, string? snippet, CancellationToken ct = default) + { + if (!IsEnabled) return EmailCategory.Personal; + + var categories = string.Join(", ", Enum.GetNames()); + var prompt = $"Classify this email into exactly one of: {categories}.\n" + + $"Subject: {subject}\nFrom: {senderAddress}\nSnippet: {snippet}\n" + + "Reply with only the single category word."; + try + { + var raw = await _provider.CompleteAsync("You are an email classifier.", prompt, ct); + return Enum.TryParse(raw.Trim(), true, out var category) ? category : EmailCategory.Personal; + } + catch + { + // AI is best-effort here; sync must never fail because the AI provider is down. + return EmailCategory.Personal; + } + } + + public async Task ScoreUnsubscribeConfidenceAsync(string senderAddress, string? recentSubject, int emailCount, CancellationToken ct = default) + { + if (!IsEnabled) return 0.5; + + var prompt = $"A user is deciding whether to unsubscribe from emails sent by \"{senderAddress}\" " + + $"(most recent subject: \"{recentSubject}\", {emailCount} emails received). " + + "Rate how SAFE it is to unsubscribe on a scale from 0 (do not unsubscribe - this looks like " + + "an important account, security, or transactional sender) to 1 (very safe - this looks like " + + "a newsletter or marketing sender). Reply with only a number between 0 and 1."; + try + { + var raw = await _provider.CompleteAsync("You assess unsubscribe safety for emails.", prompt, ct); + return double.TryParse(raw.Trim(), out var score) ? Math.Clamp(score, 0, 1) : 0.5; + } + catch + { + return 0.5; + } + } + + public async Task SummarizeEmailAsync(string? subject, string? snippet, string? bodyText, CancellationToken ct = default) + { + if (!IsEnabled) return snippet ?? string.Empty; + + var content = !string.IsNullOrWhiteSpace(bodyText) ? bodyText : snippet; + if (string.IsNullOrWhiteSpace(content)) return string.Empty; + + var prompt = $"Subject: {subject}\nBody: {Truncate(content, 4000)}\n\n" + + "Summarise this email in one short sentence (under 20 words). Reply with only the sentence."; + try + { + var summary = await _provider.CompleteAsync("You write one-line email summaries.", prompt, ct); + return summary.Trim(); + } + catch + { + return snippet ?? string.Empty; + } + } + + private static string Truncate(string s, int max) => s.Length <= max ? s : s[..max]; + public async Task SummarizeInboxAsync(Guid userId, CancellationToken ct = default) { if (!IsEnabled) return new InboxSummaryDto("AI is disabled.", Array.Empty()); diff --git a/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs b/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs index 3cad684..fa01cb1 100644 --- a/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs +++ b/src/InboxIntel.Infrastructure/Cleanup/UnsubscribeService.cs @@ -20,14 +20,25 @@ public class UnsubscribeService : IUnsubscribeService private readonly AppDbContext _db; private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; + private readonly IAiService _ai; - public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger logger) + public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger logger, IAiService ai) { _db = db; _httpFactory = httpFactory; _logger = logger; + _ai = ai; } + /// Base confidence by delivery method - one-click/HTTP links are far more reliable than mailto. + private static double MethodConfidence(UnsubscribeMethod method) => method switch + { + UnsubscribeMethod.OneClickPost => 0.9, + UnsubscribeMethod.HttpLink => 0.75, + UnsubscribeMethod.MailTo => 0.5, + _ => 0.0 + }; + public async Task DetectAsync(Guid userId, CancellationToken ct = default) { // Latest unsubscribe-bearing email per sender. @@ -39,7 +50,9 @@ public class UnsubscribeService : IUnsubscribeService SenderId = g.Key, Count = g.Count(), Raw = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.ListUnsubscribeRaw).FirstOrDefault(), - OneClick = g.Any(e => e.SupportsOneClickUnsubscribe) + OneClick = g.Any(e => e.SupportsOneClickUnsubscribe), + Address = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.Sender!.Address).FirstOrDefault(), + RecentSubject = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.Subject).FirstOrDefault() }) .ToListAsync(ct); @@ -61,6 +74,17 @@ public class UnsubscribeService : IUnsubscribeService item.UnsubscribeTarget = target; item.EmailCount = c.Count; if (item.Status == default) item.Status = UnsubscribeStatus.Detected; + + var baseConfidence = MethodConfidence(method); + if (_ai.IsEnabled && method != UnsubscribeMethod.None && c.Address is not null) + { + var aiScore = await _ai.ScoreUnsubscribeConfidenceAsync(c.Address, c.RecentSubject, c.Count, ct); + item.Confidence = (baseConfidence + aiScore) / 2; + } + else + { + item.Confidence = baseConfidence; + } } await _db.SaveChangesAsync(ct); } @@ -69,9 +93,9 @@ public class UnsubscribeService : IUnsubscribeService { return await _db.UnsubscribeItems .Where(u => u.UserId == userId && u.Method != UnsubscribeMethod.None) - .OrderByDescending(u => u.EmailCount) + .OrderByDescending(u => u.Confidence).ThenByDescending(u => u.EmailCount) .Select(u => new UnsubscribeItemDto( - u.Id, u.Sender!.Address, u.Sender.Domain!.Name, u.Method, u.Status, u.EmailCount, u.UnsubscribeTarget)) + u.Id, u.Sender!.Address, u.Sender.Domain!.Name, u.Method, u.Status, u.EmailCount, u.UnsubscribeTarget, u.Confidence)) .ToListAsync(ct); } diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs new file mode 100644 index 0000000..036a720 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.Designer.cs @@ -0,0 +1,742 @@ +// +using System; +using InboxIntel.Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using NpgsqlTypes; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260630201607_AddUnsubscribeConfidence")] + partial class AddUnsubscribeConfidence + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.4") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("InboxIntel.Domain.Entities.AnalyticsAggregate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Day") + .HasColumnType("date"); + + b.Property("HourHistogramJson") + .HasColumnType("text"); + + b.Property("NewsletterCount") + .HasColumnType("integer"); + + b.Property("TotalReceived") + .HasColumnType("integer"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("TotalUnread") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("WithAttachments") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Day") + .IsUnique(); + + b.ToTable("analytics_aggregates", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("GmailAttachmentId") + .HasColumnType("text"); + + b.Property("MimeType") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("EmailId"); + + b.HasIndex("UserId", "MimeType"); + + b.ToTable("attachments", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BodyText") + .HasColumnType("text"); + + b.Property("Category") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailMessageId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("HasAttachments") + .HasColumnType("boolean"); + + b.Property("HasListUnsubscribe") + .HasColumnType("boolean"); + + b.Property("IsImportant") + .HasColumnType("boolean"); + + b.Property("IsInInbox") + .HasColumnType("boolean"); + + b.Property("IsStarred") + .HasColumnType("boolean"); + + b.Property("IsTrashed") + .HasColumnType("boolean"); + + b.Property("IsUnread") + .HasColumnType("boolean"); + + b.Property("ListUnsubscribeRaw") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("ReceivedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SearchVector") + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("tsvector") + .HasComputedColumnSql("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", true); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("SentAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("SizeEstimateBytes") + .HasColumnType("bigint"); + + b.Property("Snippet") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("SupportsOneClickUnsubscribe") + .HasColumnType("boolean"); + + b.Property("ThreadId") + .HasColumnType("uuid"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SearchVector"); + + NpgsqlIndexBuilderExtensions.HasMethod(b.HasIndex("SearchVector"), "GIN"); + + b.HasIndex("SenderId"); + + b.HasIndex("ThreadId"); + + b.HasIndex("UserId", "Category"); + + b.HasIndex("UserId", "GmailMessageId") + .IsUnique(); + + b.HasIndex("UserId", "IsInInbox"); + + b.HasIndex("UserId", "IsUnread"); + + b.HasIndex("UserId", "SenderId"); + + b.HasIndex("UserId", "SentAtUtc"); + + b.ToTable("emails", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.Property("EmailId") + .HasColumnType("uuid"); + + b.Property("LabelId") + .HasColumnType("uuid"); + + b.HasKey("EmailId", "LabelId"); + + b.HasIndex("LabelId"); + + b.ToTable("email_labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ColorHex") + .HasColumnType("text"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailLabelId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("Type") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailLabelId") + .IsUnique(); + + b.ToTable("labels", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("IsBulkSender") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Name") + .IsUnique(); + + b.ToTable("domains", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("GmailThreadId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastMessageUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("MessageCount") + .HasColumnType("integer"); + + b.Property("Snippet") + .HasColumnType("text"); + + b.Property("Subject") + .HasMaxLength(1024) + .HasColumnType("character varying(1024)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "GmailThreadId") + .IsUnique(); + + b.ToTable("threads", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Address") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DisplayName") + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("DomainId") + .HasColumnType("uuid"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("HasUnsubscribe") + .HasColumnType("boolean"); + + b.Property("LastReceivedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("TotalSizeBytes") + .HasColumnType("bigint"); + + b.Property("UnreadCount") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("DomainId"); + + b.HasIndex("UserId", "Address") + .IsUnique(); + + b.HasIndex("UserId", "EmailCount"); + + b.ToTable("senders", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.SyncState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompletedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastError") + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("LastHistoryId") + .HasColumnType("text"); + + b.Property("LastSuccessfulSyncUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSyncType") + .HasColumnType("integer"); + + b.Property("MessagesProcessed") + .HasColumnType("integer"); + + b.Property("ResumePageToken") + .HasColumnType("text"); + + b.Property("StartedUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TotalMessagesEstimate") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId") + .IsUnique(); + + b.ToTable("sync_states", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Confidence") + .HasColumnType("double precision"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailCount") + .HasColumnType("integer"); + + b.Property("LastAttemptUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("Method") + .HasColumnType("integer"); + + b.Property("ResultMessage") + .HasColumnType("text"); + + b.Property("SenderId") + .HasColumnType("uuid"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UnsubscribeTarget") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("SenderId"); + + b.HasIndex("UserId", "SenderId") + .IsUnique(); + + b.ToTable("unsubscribe_items", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessTokenExpiresAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("DigestEnabled") + .HasColumnType("boolean"); + + b.Property("DisplayName") + .HasColumnType("text"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(320) + .HasColumnType("character varying(320)"); + + b.Property("EncryptedRefreshToken") + .HasColumnType("bytea"); + + b.Property("GoogleSubjectId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("LastDigestSentUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("LastLoginUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("PictureUrl") + .HasColumnType("text"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("GoogleSubjectId") + .IsUnique(); + + b.ToTable("users", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("H") + .HasColumnType("integer"); + + b.Property("SettingsJson") + .HasColumnType("text"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("UpdatedAtUtc") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("Visible") + .HasColumnType("boolean"); + + b.Property("W") + .HasColumnType("integer"); + + b.Property("WidgetKey") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("X") + .HasColumnType("integer"); + + b.Property("Y") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "WidgetKey") + .IsUnique(); + + b.ToTable("widget_layouts", (string)null); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Attachment", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("Attachments") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany("Emails") + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.MailThread", "Thread") + .WithMany("Emails") + .HasForeignKey("ThreadId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + + b.Navigation("Thread"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.EmailLabel", b => + { + b.HasOne("InboxIntel.Domain.Entities.Email", "Email") + .WithMany("EmailLabels") + .HasForeignKey("EmailId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("InboxIntel.Domain.Entities.Label", "Label") + .WithMany("EmailLabels") + .HasForeignKey("LabelId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Email"); + + b.Navigation("Label"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.HasOne("InboxIntel.Domain.Entities.MailDomain", "Domain") + .WithMany("Senders") + .HasForeignKey("DomainId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Domain"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.UnsubscribeItem", b => + { + b.HasOne("InboxIntel.Domain.Entities.Sender", "Sender") + .WithMany() + .HasForeignKey("SenderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Sender"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.WidgetLayout", b => + { + b.HasOne("InboxIntel.Domain.Entities.User", null) + .WithMany("WidgetLayouts") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Email", b => + { + b.Navigation("Attachments"); + + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Label", b => + { + b.Navigation("EmailLabels"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailDomain", b => + { + b.Navigation("Senders"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.MailThread", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.Sender", b => + { + b.Navigation("Emails"); + }); + + modelBuilder.Entity("InboxIntel.Domain.Entities.User", b => + { + b.Navigation("Emails"); + + b.Navigation("WidgetLayouts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs new file mode 100644 index 0000000..24a13e3 --- /dev/null +++ b/src/InboxIntel.Infrastructure/Migrations/20260630201607_AddUnsubscribeConfidence.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace InboxIntel.Infrastructure.Migrations +{ + /// + public partial class AddUnsubscribeConfidence : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Confidence", + table: "unsubscribe_items", + type: "double precision", + nullable: false, + defaultValue: 0.0); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Confidence", + table: "unsubscribe_items"); + } + } +} diff --git a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs index 90a704b..e5703f3 100644 --- a/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs +++ b/src/InboxIntel.Infrastructure/Migrations/AppDbContextModelSnapshot.cs @@ -467,6 +467,9 @@ namespace InboxIntel.Infrastructure.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("Confidence") + .HasColumnType("double precision"); + b.Property("CreatedAtUtc") .HasColumnType("timestamp with time zone"); diff --git a/src/InboxIntel.Infrastructure/Sync/SyncService.cs b/src/InboxIntel.Infrastructure/Sync/SyncService.cs index 0e001ad..7b7f15a 100644 --- a/src/InboxIntel.Infrastructure/Sync/SyncService.cs +++ b/src/InboxIntel.Infrastructure/Sync/SyncService.cs @@ -24,14 +24,16 @@ public class SyncService : ISyncService private readonly ILogger _logger; private readonly GmailSyncOptions _options; private readonly ISyncQueue _queue; + private readonly IAiService _ai; - public SyncService(AppDbContext db, IGmailService gmail, ILogger logger, IOptions options, ISyncQueue queue) + public SyncService(AppDbContext db, IGmailService gmail, ILogger logger, IOptions options, ISyncQueue queue, IAiService ai) { _db = db; _gmail = gmail; _logger = logger; _options = options.Value; _queue = queue; + _ai = ai; } public async Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default) @@ -232,6 +234,12 @@ public class SyncService : ISyncService var sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct); var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, ct); + var category = HeuristicClassifier.Classify(d); + // The heuristic rule set falls back to Personal when nothing more specific + // matches; if AI is enabled, give it a shot at a better category. + if (category == EmailCategory.Personal && _ai.IsEnabled) + category = await _ai.ClassifyFallbackAsync(d.Subject, d.FromAddress, d.Snippet, ct); + var email = new Email { UserId = userId, @@ -252,7 +260,7 @@ public class SyncService : ISyncService HasListUnsubscribe = d.HasListUnsubscribe, ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048), SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe, - Category = HeuristicClassifier.Classify(d) + Category = category }; _db.Emails.Add(email);