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
@@ -24,6 +24,12 @@ public class GmailApiService : IGmailService
private readonly ILogger<GmailApiService> _logger;
private readonly ResiliencePipeline _pipeline;
// Cache the authenticated client per user so parallel message fetches don't
// each rebuild it (which would hit the shared DbContext concurrently).
private readonly SemaphoreSlim _clientLock = new(1, 1);
private Google.Apis.Gmail.v1.GmailService? _cachedClient;
private Guid _cachedUserId;
public GmailApiService(GmailClientFactory factory, IOptions<GmailSyncOptions> options, ILogger<GmailApiService> logger)
{
_factory = factory;
@@ -56,9 +62,26 @@ public class GmailApiService : IGmailService
or HttpStatusCode.ServiceUnavailable
or HttpStatusCode.GatewayTimeout;
/// <summary>Builds the Gmail client once per user and reuses it (thread-safe).</summary>
private async Task<Google.Apis.Gmail.v1.GmailService> GetClientAsync(Guid userId, CancellationToken ct)
{
if (_cachedClient is not null && _cachedUserId == userId) return _cachedClient;
await _clientLock.WaitAsync(ct);
try
{
if (_cachedClient is null || _cachedUserId != userId)
{
_cachedClient = await _factory.CreateAsync(userId, ct);
_cachedUserId = userId;
}
return _cachedClient;
}
finally { _clientLock.Release(); }
}
public async Task<string> GetProfileHistoryIdAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var profile = await _pipeline.ExecuteAsync(async token =>
await client.Users.GetProfile("me").ExecuteAsync(token), ct);
return profile?.HistoryId?.ToString() ?? throw new InvalidOperationException("Gmail profile returned no historyId.");
@@ -66,7 +89,7 @@ public class GmailApiService : IGmailService
public async Task<GmailMessagePage> ListMessageIdsAsync(Guid userId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var page = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.List("me");
@@ -82,7 +105,7 @@ public class GmailApiService : IGmailService
public async Task<GmailMessageDetail> GetMessageAsync(Guid userId, string gmailMessageId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var msg = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.Messages.Get("me", gmailMessageId);
@@ -96,7 +119,7 @@ public class GmailApiService : IGmailService
public async Task<GmailHistoryPage> ListHistoryAsync(Guid userId, string startHistoryId, string? pageToken, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var history = await _pipeline.ExecuteAsync(async token =>
{
var req = client.Users.History.List("me");
@@ -117,7 +140,7 @@ public class GmailApiService : IGmailService
public async Task<IReadOnlyList<DomainLabel>> ListLabelsAsync(Guid userId, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var resp = await _pipeline.ExecuteAsync(async token =>
await client.Users.Labels.List("me").ExecuteAsync(token), ct);
@@ -132,7 +155,7 @@ public class GmailApiService : IGmailService
public async Task BatchModifyAsync(Guid userId, IEnumerable<string> messageIds, IEnumerable<string> addLabelIds, IEnumerable<string> removeLabelIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var body = new BatchModifyMessagesRequest
{
Ids = messageIds.ToList(),
@@ -148,7 +171,7 @@ public class GmailApiService : IGmailService
public async Task BatchTrashAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
foreach (var id in messageIds)
{
await _pipeline.ExecuteAsync(async token =>
@@ -161,7 +184,7 @@ public class GmailApiService : IGmailService
public async Task BatchDeleteAsync(Guid userId, IEnumerable<string> messageIds, CancellationToken ct = default)
{
var client = await _factory.CreateAsync(userId, ct);
var client = await GetClientAsync(userId, ct);
var body = new BatchDeleteMessagesRequest { Ids = messageIds.ToList() };
await _pipeline.ExecuteAsync(async token =>
{