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:
@@ -25,6 +25,12 @@ public class AnalyticsController : ApiControllerBase
|
||||
[HttpGet("heatmap")]
|
||||
public async Task<IActionResult> Heatmap(CancellationToken ct) => Ok(await _analytics.GetHeatmapAsync(UserId, ct));
|
||||
|
||||
[HttpGet("category-heatmap")]
|
||||
public async Task<IActionResult> CategoryHeatmap(CancellationToken ct) => Ok(await _analytics.GetCategoryHeatmapAsync(UserId, ct));
|
||||
|
||||
[HttpGet("attachments")]
|
||||
public async Task<IActionResult> Attachments(CancellationToken ct) => Ok(await _analytics.GetAttachmentBreakdownAsync(UserId, ct));
|
||||
|
||||
[HttpGet("sidebar-counts")]
|
||||
public async Task<IActionResult> SidebarCounts(CancellationToken ct) => Ok(await _analytics.GetSidebarCountsAsync(UserId, ct));
|
||||
}
|
||||
|
||||
@@ -71,6 +71,14 @@ builder.Services.AddAuthentication(options =>
|
||||
options.SaveTokens = true;
|
||||
foreach (var scope in google.Scopes) options.Scope.Add(scope);
|
||||
options.Events.OnCreatingTicket = GoogleAuthEvents.OnCreatingTicketAsync;
|
||||
// Force the consent screen so Google ALWAYS returns a refresh token.
|
||||
// Without this, Google omits the refresh token on re-authorisation,
|
||||
// leaving offline Gmail sync with no usable credential.
|
||||
options.Events.OnRedirectToAuthorizationEndpoint = context =>
|
||||
{
|
||||
context.Response.Redirect(context.RedirectUri + "&prompt=consent");
|
||||
return Task.CompletedTask;
|
||||
};
|
||||
});
|
||||
|
||||
builder.Services.AddAuthorization();
|
||||
|
||||
@@ -23,8 +23,8 @@
|
||||
"DevMode": false
|
||||
},
|
||||
"GmailSync": {
|
||||
"PageSize": 100,
|
||||
"MaxParallelism": 4,
|
||||
"PageSize": 500,
|
||||
"MaxParallelism": 8,
|
||||
"MaxRetries": 5,
|
||||
"BackoffBaseMs": 500,
|
||||
"DailySyncHourUtc": 3,
|
||||
|
||||
@@ -23,7 +23,9 @@ public interface IAnalyticsService
|
||||
Task<IReadOnlyList<SenderStatDto>> GetTopSendersAsync(Guid userId, int take = 20, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<TimeSeriesPointDto>> GetVolumeOverTimeAsync(Guid userId, int days = 90, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<HeatmapCellDto>> GetHeatmapAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default);
|
||||
Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default);
|
||||
Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,23 @@ public record TimeSeriesPointDto(DateOnly Day, int Count);
|
||||
|
||||
public record HeatmapCellDto(int DayOfWeek, int Hour, int Count);
|
||||
|
||||
/// <summary>Email counts per category per day-of-week, for the category heatmap.</summary>
|
||||
public record CategoryHeatmapCellDto(string Category, int DayOfWeek, int Count);
|
||||
|
||||
/// <summary>Counts for the sidebar: mailbox labels, special filters, and smart folders.</summary>
|
||||
public record SidebarCountsDto(
|
||||
int Inbox,
|
||||
int AllMail,
|
||||
int Unread,
|
||||
int Starred,
|
||||
int Sent,
|
||||
int Drafts,
|
||||
int Trash,
|
||||
int Spam,
|
||||
int Large,
|
||||
int Old,
|
||||
IReadOnlyDictionary<string, int> SmartFolders);
|
||||
|
||||
public record AttachmentBreakdownDto(string MimeBucket, long TotalBytes, int Count);
|
||||
|
||||
public record DashboardSummaryDto(
|
||||
|
||||
@@ -88,6 +88,19 @@ public class AnalyticsService : IAnalyticsService
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CategoryHeatmapCellDto>> GetCategoryHeatmapAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Emails
|
||||
.Where(e => e.UserId == userId)
|
||||
.Select(e => new { e.SentAtUtc, e.Category })
|
||||
.ToListAsync(ct);
|
||||
|
||||
return raw
|
||||
.GroupBy(x => new { x.Category, Dow = (int)x.SentAtUtc.DayOfWeek })
|
||||
.Select(g => new CategoryHeatmapCellDto(g.Key.Category.ToString(), g.Key.Dow, g.Count()))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<AttachmentBreakdownDto>> GetAttachmentBreakdownAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var raw = await _db.Attachments
|
||||
@@ -115,6 +128,60 @@ public class AnalyticsService : IAnalyticsService
|
||||
_ => "other"
|
||||
};
|
||||
|
||||
public async Task<SidebarCountsDto> GetSidebarCountsAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var emails = _db.Emails.Where(e => e.UserId == userId);
|
||||
|
||||
var allMail = await emails.CountAsync(ct);
|
||||
var inbox = await emails.CountAsync(e => e.IsInInbox, ct);
|
||||
var unread = await emails.CountAsync(e => e.IsUnread, ct);
|
||||
var starred = await emails.CountAsync(e => e.IsStarred, ct);
|
||||
var trash = await emails.CountAsync(e => e.IsTrashed, ct);
|
||||
var large = await emails.CountAsync(e => e.SizeEstimateBytes > 5_000_000, ct);
|
||||
var cutoff = DateTimeOffset.UtcNow.AddYears(-1);
|
||||
var old = await emails.CountAsync(e => e.SentAtUtc < cutoff, ct);
|
||||
|
||||
// Label-backed counts (SENT / DRAFT / SPAM are system Gmail labels)
|
||||
async Task<int> LabelCount(string gmailId)
|
||||
{
|
||||
var labelId = await _db.Labels
|
||||
.Where(l => l.UserId == userId && l.GmailLabelId == gmailId)
|
||||
.Select(l => (Guid?)l.Id)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
return labelId.HasValue
|
||||
? await _db.EmailLabels.CountAsync(el => el.LabelId == labelId.Value, ct)
|
||||
: 0;
|
||||
}
|
||||
|
||||
var sent = await LabelCount("SENT");
|
||||
var drafts = await LabelCount("DRAFT");
|
||||
var spam = await LabelCount("SPAM");
|
||||
|
||||
// Category → smart-folder slug mapping
|
||||
var catCounts = await emails
|
||||
.GroupBy(e => e.Category)
|
||||
.Select(g => new { Cat = g.Key, N = g.Count() })
|
||||
.ToListAsync(ct);
|
||||
|
||||
int Cat(EmailCategory c) => catCounts.FirstOrDefault(x => x.Cat == c)?.N ?? 0;
|
||||
|
||||
var smartFolders = new Dictionary<string, int>
|
||||
{
|
||||
["automated"] = Cat(EmailCategory.Notification),
|
||||
["finance"] = Cat(EmailCategory.Finance),
|
||||
["social"] = Cat(EmailCategory.Social),
|
||||
["shopping"] = Cat(EmailCategory.Promotional),
|
||||
["noreply"] = 0,
|
||||
["gaming"] = 0,
|
||||
["sales"] = 0,
|
||||
["ridesharing"] = 0,
|
||||
["food"] = 0,
|
||||
["wellness"] = 0,
|
||||
};
|
||||
|
||||
return new SidebarCountsDto(inbox, allMail, unread, starred, sent, drafts, trash, spam, large, old, smartFolders);
|
||||
}
|
||||
|
||||
public async Task RefreshAggregatesAsync(Guid userId, CancellationToken ct = default)
|
||||
{
|
||||
var since = DateTimeOffset.UtcNow.AddDays(-365);
|
||||
|
||||
@@ -19,8 +19,10 @@ public class GoogleOAuthOptions
|
||||
public class GmailSyncOptions
|
||||
{
|
||||
public const string SectionName = "GmailSync";
|
||||
public int PageSize { get; set; } = 100;
|
||||
public int MaxParallelism { get; set; } = 4;
|
||||
/// <summary>Message-id list page size (Gmail max is 500).</summary>
|
||||
public int PageSize { get; set; } = 500;
|
||||
/// <summary>Concurrent Gmail message fetches during sync (kept well under quota).</summary>
|
||||
public int MaxParallelism { get; set; } = 8;
|
||||
public int MaxRetries { get; set; } = 5;
|
||||
/// <summary>Base delay in ms for exponential backoff.</summary>
|
||||
public int BackoffBaseMs { get; set; } = 500;
|
||||
|
||||
@@ -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 =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user