feat: AI categorisation fallback, unsubscribe confidence scoring, one-line summaries
Heuristic classifier falls back to AI only when it can't determine a category; unsubscribe confidence blends method reliability with an AI safety opinion and surfaces it in the Unsubscribe Manager; emails get an on-demand AI one-line summary in the detail pane. All AI calls degrade gracefully when AI is disabled or the provider errors. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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`),
|
||||
|
||||
@@ -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 (
|
||||
<div className="sd-detail">
|
||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||
@@ -101,6 +112,18 @@ function EmailDetail({ email: summary, onBack }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && (
|
||||
<div className="sd-ai-summary">
|
||||
{aiSummary != null ? (
|
||||
<div className="sd-ai-summary-text">✨ {aiSummary}</div>
|
||||
) : (
|
||||
<button className="btn-sm" onClick={fetchAiSummary} disabled={aiLoading}>
|
||||
{aiLoading ? 'Summarising…' : '✨ AI summary'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <div className="sd-body-loading muted">Loading message…</div>}
|
||||
|
||||
{!loading && detail?.bodyText && (
|
||||
|
||||
@@ -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() {
|
||||
<th>Domain</th>
|
||||
<th>Method</th>
|
||||
<th className="num">Emails</th>
|
||||
<th>Confidence</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -108,11 +109,16 @@ export default function Unsubscribe() {
|
||||
<td className="muted">{it.domain}</td>
|
||||
<td>{METHOD[it.method]}</td>
|
||||
<td className="num">{it.emailCount.toLocaleString()}</td>
|
||||
<td>
|
||||
<span className={`unsub-confidence unsub-confidence--${it.confidence >= 0.7 ? 'high' : it.confidence >= 0.4 ? 'mid' : 'low'}`}>
|
||||
{Math.round(it.confidence * 100)}%
|
||||
</span>
|
||||
</td>
|
||||
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
{!visible.length && (
|
||||
<tr><td colSpan="6" className="muted">
|
||||
<tr><td colSpan="7" className="muted">
|
||||
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||
</td></tr>
|
||||
)}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <summary>One-line AI summary, fetched on demand (returns the snippet verbatim if AI is disabled).</summary>
|
||||
[HttpGet("{id:guid}/summary")]
|
||||
public async Task<IActionResult> 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<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
||||
|
||||
|
||||
@@ -55,6 +55,21 @@ public interface IAiService
|
||||
{
|
||||
bool IsEnabled { get; }
|
||||
Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<EmailCategory> ClassifyFallbackAsync(string? subject, string senderAddress, string? snippet, CancellationToken ct = default);
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
Task<double> ScoreUnsubscribeConfidenceAsync(string senderAddress, string? recentSubject, int emailCount, CancellationToken ct = default);
|
||||
/// <summary>One-line plain-language summary of a single email, for list/preview UIs.</summary>
|
||||
Task<string> SummarizeEmailAsync(string? subject, string? snippet, string? bodyText, CancellationToken ct = default);
|
||||
Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default);
|
||||
|
||||
@@ -33,6 +33,7 @@ public record UnsubscribeItemDto(
|
||||
UnsubscribeMethod Method,
|
||||
UnsubscribeStatus Status,
|
||||
int EmailCount,
|
||||
string? UnsubscribeTarget);
|
||||
string? UnsubscribeTarget,
|
||||
double Confidence);
|
||||
|
||||
public record UnsubscribeRequestDto(IReadOnlyList<Guid> ItemIds, bool Confirmed);
|
||||
|
||||
@@ -26,4 +26,11 @@ public class UnsubscribeItem : AuditableEntity
|
||||
|
||||
public DateTimeOffset? LastAttemptUtc { get; set; }
|
||||
public string? ResultMessage { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public double Confidence { get; set; }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,68 @@ public class AiService : IAiService
|
||||
return new AiClassificationDto(emailId, category, raw.Length > 0 ? 0.8 : 0);
|
||||
}
|
||||
|
||||
public async Task<EmailCategory> ClassifyFallbackAsync(string? subject, string senderAddress, string? snippet, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return EmailCategory.Personal;
|
||||
|
||||
var categories = string.Join(", ", Enum.GetNames<EmailCategory>());
|
||||
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<EmailCategory>(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<double> 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<string> 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<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
if (!IsEnabled) return new InboxSummaryDto("AI is disabled.", Array.Empty<string>());
|
||||
|
||||
@@ -20,14 +20,25 @@ public class UnsubscribeService : IUnsubscribeService
|
||||
private readonly AppDbContext _db;
|
||||
private readonly IHttpClientFactory _httpFactory;
|
||||
private readonly ILogger<UnsubscribeService> _logger;
|
||||
private readonly IAiService _ai;
|
||||
|
||||
public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger<UnsubscribeService> logger)
|
||||
public UnsubscribeService(AppDbContext db, IHttpClientFactory httpFactory, ILogger<UnsubscribeService> logger, IAiService ai)
|
||||
{
|
||||
_db = db;
|
||||
_httpFactory = httpFactory;
|
||||
_logger = logger;
|
||||
_ai = ai;
|
||||
}
|
||||
|
||||
/// <summary>Base confidence by delivery method - one-click/HTTP links are far more reliable than mailto.</summary>
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
Generated
+742
@@ -0,0 +1,742 @@
|
||||
// <auto-generated />
|
||||
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
|
||||
{
|
||||
/// <inheritdoc />
|
||||
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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateOnly>("Day")
|
||||
.HasColumnType("date");
|
||||
|
||||
b.Property<string>("HourHistogramJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("NewsletterCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalReceived")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("TotalUnread")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("FileName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(512)
|
||||
.HasColumnType("character varying(512)");
|
||||
|
||||
b.Property<string>("GmailAttachmentId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("MimeType")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<long>("SizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("BodyText")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("Category")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailMessageId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<bool>("HasAttachments")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("HasListUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsImportant")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsInInbox")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsStarred")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsTrashed")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsUnread")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("ListUnsubscribeRaw")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("ReceivedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<NpgsqlTsVector>("SearchVector")
|
||||
.ValueGeneratedOnAddOrUpdate()
|
||||
.HasColumnType("tsvector")
|
||||
.HasComputedColumnSql("to_tsvector('english', coalesce(\"Subject\",'') || ' ' || coalesce(\"BodyText\",''))", true);
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("SentAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("SizeEstimateBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<bool>("SupportsOneClickUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<Guid>("ThreadId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("EmailId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("ColorHex")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailLabelId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<string>("Type")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("IsBulkSender")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("FirstMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("GmailThreadId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastMessageUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("MessageCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Snippet")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subject")
|
||||
.HasMaxLength(1024)
|
||||
.HasColumnType("character varying(1024)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Address")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasMaxLength(255)
|
||||
.HasColumnType("character varying(255)");
|
||||
|
||||
b.Property<Guid>("DomainId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<bool>("HasUnsubscribe")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastReceivedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<long>("TotalSizeBytes")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b.Property<int>("UnreadCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("CompletedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("ConsecutiveFailures")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("LastError")
|
||||
.HasMaxLength(4000)
|
||||
.HasColumnType("character varying(4000)");
|
||||
|
||||
b.Property<string>("LastHistoryId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastSuccessfulSyncUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("LastSyncType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("MessagesProcessed")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResumePageToken")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("StartedUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("TotalMessagesEstimate")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("EmailCount")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastAttemptUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("Method")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("ResultMessage")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<Guid>("SenderId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("UnsubscribeTarget")
|
||||
.HasMaxLength(2048)
|
||||
.HasColumnType("character varying(2048)");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset?>("AccessTokenExpiresAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<bool>("DigestEnabled")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("DisplayName")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Email")
|
||||
.IsRequired()
|
||||
.HasMaxLength(320)
|
||||
.HasColumnType("character varying(320)");
|
||||
|
||||
b.Property<byte[]>("EncryptedRefreshToken")
|
||||
.HasColumnType("bytea");
|
||||
|
||||
b.Property<string>("GoogleSubjectId")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastDigestSentUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTimeOffset?>("LastLoginUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<string>("PictureUrl")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTimeOffset?>("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<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<int>("H")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("SettingsJson")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTimeOffset?>("UpdatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("UserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("Visible")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("W")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("WidgetKey")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)");
|
||||
|
||||
b.Property<int>("X")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace InboxIntel.Infrastructure.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddUnsubscribeConfidence : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "Confidence",
|
||||
table: "unsubscribe_items",
|
||||
type: "double precision",
|
||||
nullable: false,
|
||||
defaultValue: 0.0);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Confidence",
|
||||
table: "unsubscribe_items");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -467,6 +467,9 @@ namespace InboxIntel.Infrastructure.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Confidence")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
|
||||
@@ -24,14 +24,16 @@ public class SyncService : ISyncService
|
||||
private readonly ILogger<SyncService> _logger;
|
||||
private readonly GmailSyncOptions _options;
|
||||
private readonly ISyncQueue _queue;
|
||||
private readonly IAiService _ai;
|
||||
|
||||
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger, IOptions<GmailSyncOptions> options, ISyncQueue queue)
|
||||
public SyncService(AppDbContext db, IGmailService gmail, ILogger<SyncService> logger, IOptions<GmailSyncOptions> 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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user