feat: Smart Folders sidebar, category heatmap, sidebar counts API

- Replace plain activity heatmap with CategoryHeatmapWidget on dashboard
- Add collapsible Smart Folders sidebar with Favorites, Mailbox, Smart Folders sections
- Favorites: customisable pinned shortcuts (default: Inbox, Unread, Large, Old, Read Later); pin/unpin smart folders via buttons; persists to localStorage
- Mailbox section: 11 Gmail mailbox items with icons and live count badges
- Smart Folders section: 10 category folders sorted by count desc
- Live count badges via new GET /api/v1/analytics/sidebar-counts endpoint
- Backend: SidebarCountsDto, GetSidebarCountsAsync with label lookups for SENT/DRAFT/SPAM, size/age filters, EmailCategory mapping
- Sidebar collapses to icon-only; section states persist to localStorage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-06-30 19:40:54 +02:00
parent fe5920919f
commit ca02f40841
17 changed files with 2866 additions and 66 deletions
@@ -54,9 +54,11 @@ public class SyncService : ISyncService
public async Task<SyncProgressDto> GetProgressAsync(Guid userId, CancellationToken ct = default)
{
var s = await GetOrCreateStateAsync(userId, ct);
var total = _options.MaxMessages > 0
? Math.Min(s.TotalMessagesEstimate == 0 ? _options.MaxMessages : s.TotalMessagesEstimate, _options.MaxMessages)
: s.TotalMessagesEstimate;
// Total is unknown (0 -> indeterminate bar) until Gmail returns the real
// count; then it's the mailbox total, capped by MaxMessages in dev.
var total = s.TotalMessagesEstimate;
if (_options.MaxMessages > 0 && total > 0)
total = Math.Min(total, _options.MaxMessages);
return new SyncProgressDto(
s.Status.ToString(), s.MessagesProcessed, total,
s.Status == SyncStatus.Running, s.LastSuccessfulSyncUtc, s.LastError);
@@ -77,34 +79,48 @@ public class SyncService : ISyncService
// Dev cap: stop after MaxMessages (most recent first). 0 = unlimited.
var maxMessages = _options.MaxMessages;
var capReached = false;
string? pageToken = state.ResumePageToken; // resume support
// Phase 1 — enumerate message ids (cheap, ids only) to get an ACCURATE
// total. Gmail's resultSizeEstimate is unreliable, so we count instead.
var ids = new List<string>();
string? listToken = null;
do
{
var page = await _gmail.ListMessageIdsAsync(userId, pageToken, ct);
foreach (var messageId in page.MessageIds)
var page = await _gmail.ListMessageIdsAsync(userId, listToken, ct);
foreach (var id in page.MessageIds)
{
ids.Add(id);
if (maxMessages > 0 && ids.Count >= maxMessages) break;
}
listToken = page.NextPageToken;
}
while (listToken is not null && (maxMessages == 0 || ids.Count < maxMessages) && !ct.IsCancellationRequested);
state.TotalMessagesEstimate = ids.Count; // the true target (capped in dev)
await _db.SaveChangesAsync(ct);
// Phase 2 — fetch bodies in chunks, in parallel, skipping ones we already
// have. The DbContext is not thread-safe, so upserts run sequentially.
const int chunkSize = 100;
for (var i = 0; i < ids.Count && !ct.IsCancellationRequested; i += chunkSize)
{
var chunk = ids.GetRange(i, Math.Min(chunkSize, ids.Count - i));
var have = (await _db.Emails
.Where(e => e.UserId == userId && chunk.Contains(e.GmailMessageId))
.Select(e => e.GmailMessageId).ToListAsync(ct)).ToHashSet();
state.MessagesProcessed += have.Count; // already-synced count toward progress
var toFetch = chunk.Where(id => !have.Contains(id)).ToList();
var details = await FetchDetailsParallelAsync(userId, toFetch, ct);
foreach (var detail in details)
{
if (maxMessages > 0 && state.MessagesProcessed >= maxMessages)
{
capReached = true;
break;
}
if (await _db.Emails.AnyAsync(e => e.UserId == userId && e.GmailMessageId == messageId, ct))
continue;
var detail = await _gmail.GetMessageAsync(userId, messageId, ct);
await UpsertMessageAsync(userId, detail, ct);
state.MessagesProcessed++;
if (state.MessagesProcessed % 25 == 0)
await _db.SaveChangesAsync(ct); // smooth progress for the splash
}
pageToken = page.NextPageToken;
state.ResumePageToken = pageToken; // checkpoint
state.TotalMessagesEstimate = maxMessages > 0
? Math.Min(page.ResultSizeEstimate, maxMessages)
: page.ResultSizeEstimate;
await _db.SaveChangesAsync(ct);
}
while (pageToken is not null && !capReached && !ct.IsCancellationRequested);
state.LastHistoryId = await _gmail.GetProfileHistoryIdAsync(userId, ct);
state.ResumePageToken = null;
@@ -175,9 +191,14 @@ public class SyncService : ISyncService
private async Task MarkFailedAsync(SyncState state, Exception ex, CancellationToken ct)
{
_logger.LogError(ex, "Sync failed for user {UserId}", state.UserId);
state.Status = SyncStatus.Failed;
state.ConsecutiveFailures++;
state.LastError = ex.Message;
// Discard the failed batch's pending changes; otherwise saving the
// failure status re-attempts the same bad inserts and throws again.
_db.ChangeTracker.Clear();
var fresh = await _db.SyncStates.FirstOrDefaultAsync(s => s.UserId == state.UserId, ct);
if (fresh is null) return;
fresh.Status = SyncStatus.Failed;
fresh.ConsecutiveFailures++;
fresh.LastError = ex.Message;
await _db.SaveChangesAsync(ct);
}
@@ -217,9 +238,9 @@ public class SyncService : ISyncService
GmailMessageId = d.GmailMessageId,
ThreadId = thread.Id,
SenderId = sender.Id,
Subject = d.Subject,
Snippet = d.Snippet,
BodyText = d.BodyText,
Subject = Trunc(d.Subject, 1024),
Snippet = Trunc(d.Snippet, 2048),
BodyText = d.BodyText, // unlimited (text column)
SentAtUtc = d.SentAtUtc,
ReceivedAtUtc = d.SentAtUtc,
SizeEstimateBytes = d.SizeEstimateBytes,
@@ -229,7 +250,7 @@ public class SyncService : ISyncService
IsImportant = d.LabelIds.Contains("IMPORTANT"),
HasAttachments = d.HasAttachments,
HasListUnsubscribe = d.HasListUnsubscribe,
ListUnsubscribeRaw = d.ListUnsubscribeRaw,
ListUnsubscribeRaw = Trunc(d.ListUnsubscribeRaw, 2048),
SupportsOneClickUnsubscribe = d.SupportsOneClickUnsubscribe,
Category = HeuristicClassifier.Classify(d)
};
@@ -239,8 +260,8 @@ public class SyncService : ISyncService
{
_db.Attachments.Add(new Attachment
{
UserId = userId, EmailId = email.Id, FileName = fileName,
MimeType = mime, SizeBytes = size, GmailAttachmentId = attId
UserId = userId, EmailId = email.Id, FileName = Trunc(fileName, 512) ?? string.Empty,
MimeType = Trunc(mime, 255), SizeBytes = size, GmailAttachmentId = attId
});
}
@@ -255,13 +276,37 @@ public class SyncService : ISyncService
thread.LastMessageUtc = d.SentAtUtc;
}
/// <summary>
/// Fetches message bodies concurrently (bounded by MaxParallelism) to speed
/// up the sync. Results only — the caller persists them sequentially.
/// </summary>
private async Task<List<GmailMessageDetail>> FetchDetailsParallelAsync(Guid userId, List<string> ids, CancellationToken ct)
{
if (ids.Count == 0) return new List<GmailMessageDetail>();
using var sem = new SemaphoreSlim(Math.Max(1, _options.MaxParallelism));
var tasks = ids.Select(async id =>
{
await sem.WaitAsync(ct);
try { return await _gmail.GetMessageAsync(userId, id, ct); }
finally { sem.Release(); }
});
var results = await Task.WhenAll(tasks);
return results.ToList();
}
private async Task<Sender> ResolveSenderAsync(Guid userId, string address, string? displayName, CancellationToken ct)
{
var sender = await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
// Check the in-memory tracker first so senders/domains added earlier in
// this batch (not yet saved) are reused instead of duplicated.
var sender = _db.Senders.Local.FirstOrDefault(s => s.UserId == userId && s.Address == address)
?? await _db.Senders.FirstOrDefaultAsync(s => s.UserId == userId && s.Address == address, ct);
if (sender is not null) return sender;
var domainName = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domain = await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
address = Trunc(address, 320)!;
var rawDomain = address.Contains('@') ? address[(address.IndexOf('@') + 1)..] : "unknown";
var domainName = Trunc(rawDomain, 255)!;
var domain = _db.Domains.Local.FirstOrDefault(x => x.UserId == userId && x.Name == domainName)
?? await _db.Domains.FirstOrDefaultAsync(x => x.UserId == userId && x.Name == domainName, ct);
if (domain is null)
{
domain = new MailDomain { UserId = userId, Name = domainName };
@@ -269,19 +314,24 @@ public class SyncService : ISyncService
}
domain.EmailCount++;
sender = new Sender { UserId = userId, Address = address, DisplayName = displayName, Domain = domain, DomainId = domain.Id };
sender = new Sender { UserId = userId, Address = address, DisplayName = Trunc(displayName, 255), Domain = domain, DomainId = domain.Id };
_db.Senders.Add(sender);
return sender;
}
/// <summary>Truncates a string to a column's max length so no email can overflow it.</summary>
private static string? Trunc(string? value, int max)
=> value is null ? null : value.Length <= max ? value : value[..max];
private async Task<MailThread> ResolveThreadAsync(Guid userId, string gmailThreadId, string? subject, string? snippet, DateTimeOffset sentAt, CancellationToken ct)
{
var thread = await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
var thread = _db.Threads.Local.FirstOrDefault(t => t.UserId == userId && t.GmailThreadId == gmailThreadId)
?? await _db.Threads.FirstOrDefaultAsync(t => t.UserId == userId && t.GmailThreadId == gmailThreadId, ct);
if (thread is not null) return thread;
thread = new MailThread
{
UserId = userId, GmailThreadId = gmailThreadId, Subject = subject,
Snippet = snippet, FirstMessageUtc = sentAt
UserId = userId, GmailThreadId = gmailThreadId, Subject = Trunc(subject, 1024),
Snippet = Trunc(snippet, 2048), FirstMessageUtc = sentAt
};
_db.Threads.Add(thread);
return thread;