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 = {
|
export const EmailApi = {
|
||||||
get: (id) => api.get(`/email/${id}`).then((r) => r.data),
|
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`),
|
markRead: (id) => api.post(`/email/${id}/read`),
|
||||||
markUnread: (id) => api.post(`/email/${id}/unread`),
|
markUnread: (id) => api.post(`/email/${id}/unread`),
|
||||||
star: (id) => api.post(`/email/${id}/star`),
|
star: (id) => api.post(`/email/${id}/star`),
|
||||||
|
|||||||
@@ -71,10 +71,13 @@ function SenderList({ senders, selectedId, onSelect, search, onSearch }) {
|
|||||||
function EmailDetail({ email: summary, onBack }) {
|
function EmailDetail({ email: summary, onBack }) {
|
||||||
const [detail, setDetail] = useState(null);
|
const [detail, setDetail] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [aiSummary, setAiSummary] = useState(null);
|
||||||
|
const [aiLoading, setAiLoading] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDetail(null);
|
setDetail(null);
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setAiSummary(null);
|
||||||
EmailApi.get(summary.id)
|
EmailApi.get(summary.id)
|
||||||
.then(setDetail)
|
.then(setDetail)
|
||||||
.catch(() => setDetail(null))
|
.catch(() => setDetail(null))
|
||||||
@@ -83,6 +86,14 @@ function EmailDetail({ email: summary, onBack }) {
|
|||||||
|
|
||||||
const email = detail ?? summary;
|
const email = detail ?? summary;
|
||||||
|
|
||||||
|
const fetchAiSummary = () => {
|
||||||
|
setAiLoading(true);
|
||||||
|
EmailApi.summary(summary.id)
|
||||||
|
.then((r) => setAiSummary(r.summary))
|
||||||
|
.catch(() => setAiSummary(null))
|
||||||
|
.finally(() => setAiLoading(false));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sd-detail">
|
<div className="sd-detail">
|
||||||
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
<button className="sd-back" onClick={onBack}>← Back to list</button>
|
||||||
@@ -101,6 +112,18 @@ function EmailDetail({ email: summary, onBack }) {
|
|||||||
</div>
|
</div>
|
||||||
</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 && <div className="sd-body-loading muted">Loading message…</div>}
|
||||||
|
|
||||||
{!loading && detail?.bodyText && (
|
{!loading && detail?.bodyText && (
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function Unsubscribe() {
|
|||||||
const visible = useMemo(() => {
|
const visible = useMemo(() => {
|
||||||
const f = FILTERS.find((f) => f.key === filter);
|
const f = FILTERS.find((f) => f.key === filter);
|
||||||
const list = !f?.statuses ? items : items.filter((i) => f.statuses.includes(i.status));
|
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]);
|
}, [items, filter]);
|
||||||
|
|
||||||
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
const allVisibleSelected = visible.length > 0 && visible.every((it) => selected[it.id]);
|
||||||
@@ -97,6 +97,7 @@ export default function Unsubscribe() {
|
|||||||
<th>Domain</th>
|
<th>Domain</th>
|
||||||
<th>Method</th>
|
<th>Method</th>
|
||||||
<th className="num">Emails</th>
|
<th className="num">Emails</th>
|
||||||
|
<th>Confidence</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -108,11 +109,16 @@ export default function Unsubscribe() {
|
|||||||
<td className="muted">{it.domain}</td>
|
<td className="muted">{it.domain}</td>
|
||||||
<td>{METHOD[it.method]}</td>
|
<td>{METHOD[it.method]}</td>
|
||||||
<td className="num">{it.emailCount.toLocaleString()}</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>
|
<td><span className={`unsub-badge unsub-badge--${STATUS_CLASS[it.status]}`}>{STATUS[it.status]}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{!visible.length && (
|
{!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.'}
|
{items.length === 0 ? 'Nothing detected yet. Run a scan.' : 'No items in this filter.'}
|
||||||
</td></tr>
|
</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-meta { font-size: 12px; color: var(--muted); display: flex; flex-wrap: wrap; gap: 4px; align-items: center; }
|
||||||
.sd-detail-sep { opacity: 0.4; }
|
.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-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; }
|
.sd-detail-actions { display: flex; gap: 10px; }
|
||||||
.btn-sm {
|
.btn-sm {
|
||||||
background: var(--accent); color: #fff; border: none; border-radius: 6px;
|
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--ok { color: var(--ok); border-color: var(--ok); }
|
||||||
.unsub-badge--fail { color: var(--danger); border-color: var(--danger); }
|
.unsub-badge--fail { color: var(--danger); border-color: var(--danger); }
|
||||||
.unsub-badge--skip { color: var(--muted); }
|
.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 selection toolbar (folder/search/sender email lists) ──────────── */
|
||||||
.bulk-toolbar {
|
.bulk-toolbar {
|
||||||
|
|||||||
@@ -18,12 +18,14 @@ public class EmailController : ApiControllerBase
|
|||||||
{
|
{
|
||||||
private readonly ICleanupService _cleanup;
|
private readonly ICleanupService _cleanup;
|
||||||
private readonly IUnsubscribeService _unsub;
|
private readonly IUnsubscribeService _unsub;
|
||||||
|
private readonly IAiService _ai;
|
||||||
private readonly AppDbContext _db;
|
private readonly AppDbContext _db;
|
||||||
|
|
||||||
public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, AppDbContext db)
|
public EmailController(ICleanupService cleanup, IUnsubscribeService unsub, IAiService ai, AppDbContext db)
|
||||||
{
|
{
|
||||||
_cleanup = cleanup;
|
_cleanup = cleanup;
|
||||||
_unsub = unsub;
|
_unsub = unsub;
|
||||||
|
_ai = ai;
|
||||||
_db = db;
|
_db = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,6 +44,16 @@ public class EmailController : ApiControllerBase
|
|||||||
return e is null ? NotFound() : Ok(e);
|
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")]
|
[HttpPost("{id:guid}/read")]
|
||||||
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
public Task<IActionResult> MarkRead(Guid id, CancellationToken ct) => Act(id, CleanupActionType.MarkRead, ct);
|
||||||
|
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ public interface IAiService
|
|||||||
{
|
{
|
||||||
bool IsEnabled { get; }
|
bool IsEnabled { get; }
|
||||||
Task<AiClassificationDto> ClassifyAsync(Guid userId, Guid emailId, CancellationToken ct = default);
|
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<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default);
|
Task<IReadOnlyList<AiCleanupSuggestionDto>> SuggestCleanupAsync(Guid userId, CancellationToken ct = default);
|
||||||
Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default);
|
Task<GeneratedQueryDto> GenerateQueryAsync(Guid userId, string naturalLanguage, CancellationToken ct = default);
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ public record UnsubscribeItemDto(
|
|||||||
UnsubscribeMethod Method,
|
UnsubscribeMethod Method,
|
||||||
UnsubscribeStatus Status,
|
UnsubscribeStatus Status,
|
||||||
int EmailCount,
|
int EmailCount,
|
||||||
string? UnsubscribeTarget);
|
string? UnsubscribeTarget,
|
||||||
|
double Confidence);
|
||||||
|
|
||||||
public record UnsubscribeRequestDto(IReadOnlyList<Guid> ItemIds, bool Confirmed);
|
public record UnsubscribeRequestDto(IReadOnlyList<Guid> ItemIds, bool Confirmed);
|
||||||
|
|||||||
@@ -26,4 +26,11 @@ public class UnsubscribeItem : AuditableEntity
|
|||||||
|
|
||||||
public DateTimeOffset? LastAttemptUtc { get; set; }
|
public DateTimeOffset? LastAttemptUtc { get; set; }
|
||||||
public string? ResultMessage { 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);
|
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)
|
public async Task<InboxSummaryDto> SummarizeInboxAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
if (!IsEnabled) return new InboxSummaryDto("AI is disabled.", Array.Empty<string>());
|
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 AppDbContext _db;
|
||||||
private readonly IHttpClientFactory _httpFactory;
|
private readonly IHttpClientFactory _httpFactory;
|
||||||
private readonly ILogger<UnsubscribeService> _logger;
|
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;
|
_db = db;
|
||||||
_httpFactory = httpFactory;
|
_httpFactory = httpFactory;
|
||||||
_logger = logger;
|
_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)
|
public async Task DetectAsync(Guid userId, CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
// Latest unsubscribe-bearing email per sender.
|
// Latest unsubscribe-bearing email per sender.
|
||||||
@@ -39,7 +50,9 @@ public class UnsubscribeService : IUnsubscribeService
|
|||||||
SenderId = g.Key,
|
SenderId = g.Key,
|
||||||
Count = g.Count(),
|
Count = g.Count(),
|
||||||
Raw = g.OrderByDescending(e => e.SentAtUtc).Select(e => e.ListUnsubscribeRaw).FirstOrDefault(),
|
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);
|
.ToListAsync(ct);
|
||||||
|
|
||||||
@@ -61,6 +74,17 @@ public class UnsubscribeService : IUnsubscribeService
|
|||||||
item.UnsubscribeTarget = target;
|
item.UnsubscribeTarget = target;
|
||||||
item.EmailCount = c.Count;
|
item.EmailCount = c.Count;
|
||||||
if (item.Status == default) item.Status = UnsubscribeStatus.Detected;
|
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);
|
await _db.SaveChangesAsync(ct);
|
||||||
}
|
}
|
||||||
@@ -69,9 +93,9 @@ public class UnsubscribeService : IUnsubscribeService
|
|||||||
{
|
{
|
||||||
return await _db.UnsubscribeItems
|
return await _db.UnsubscribeItems
|
||||||
.Where(u => u.UserId == userId && u.Method != UnsubscribeMethod.None)
|
.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(
|
.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);
|
.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()
|
.ValueGeneratedOnAdd()
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<double>("Confidence")
|
||||||
|
.HasColumnType("double precision");
|
||||||
|
|
||||||
b.Property<DateTimeOffset>("CreatedAtUtc")
|
b.Property<DateTimeOffset>("CreatedAtUtc")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
|||||||
@@ -24,14 +24,16 @@ public class SyncService : ISyncService
|
|||||||
private readonly ILogger<SyncService> _logger;
|
private readonly ILogger<SyncService> _logger;
|
||||||
private readonly GmailSyncOptions _options;
|
private readonly GmailSyncOptions _options;
|
||||||
private readonly ISyncQueue _queue;
|
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;
|
_db = db;
|
||||||
_gmail = gmail;
|
_gmail = gmail;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_options = options.Value;
|
_options = options.Value;
|
||||||
_queue = queue;
|
_queue = queue;
|
||||||
|
_ai = ai;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task QueueSyncAsync(Guid userId, bool fullSync, CancellationToken ct = default)
|
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 sender = await ResolveSenderAsync(userId, d.FromAddress, d.FromDisplayName, ct);
|
||||||
var thread = await ResolveThreadAsync(userId, d.GmailThreadId, d.Subject, d.Snippet, d.SentAtUtc, 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
|
var email = new Email
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
@@ -252,7 +260,7 @@ public class SyncService : ISyncService
|
|||||||
HasListUnsubscribe = d.HasListUnsubscribe,
|
HasListUnsubscribe = d.HasListUnsubscribe,
|
||||||
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
|
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
|
||||||
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
|
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
|
||||||
Category = HeuristicClassifier.Classify(d)
|
Category = category
|
||||||
};
|
};
|
||||||
_db.Emails.Add(email);
|
_db.Emails.Add(email);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user