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);