feat: harden gmail sync foundation

This commit is contained in:
2026-04-01 16:09:29 +02:00
parent 068ce447c0
commit e5bcf9d5ea
11 changed files with 435 additions and 115 deletions
+162 -105
View File
@@ -116,6 +116,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
existing.LastSyncStatus = "connected";
existing.LastSyncSource = "oauth-callback";
existing.LastSyncMode = "connect";
existing.LastSyncError = null;
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
await _db.SaveChangesAsync(cancellationToken);
return new GmailOAuthExchangeResult(existing.GmailAddress);
@@ -148,40 +154,49 @@ public sealed class GmailOAuthService : IGmailOAuthService
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
try
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages?maxResults={maxResults}";
if (!string.IsNullOrWhiteSpace(query))
{
url += $"&q={Uri.EscapeDataString(query.Trim())}";
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return results;
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
catch (Exception ex)
{
return Array.Empty<GmailMessageSummary>();
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
var ids = messagesElement.EnumerateArray()
.Select(x => x.TryGetProperty("id", out var id) ? id.GetString() : null)
.Where(x => !string.IsNullOrWhiteSpace(x))
.Cast<string>()
.ToList();
var results = new List<GmailMessageSummary>(ids.Count);
foreach (var id in ids)
{
var detail = await GetMessageAsync(ownerUserId, id, cancellationToken);
results.Add(new GmailMessageSummary(detail.Id, detail.ThreadId, detail.Subject, detail.From, detail.To, detail.Date, detail.Snippet));
}
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
@@ -233,93 +248,111 @@ public sealed class GmailOAuthService : IGmailOAuthService
return Array.Empty<GmailMessageSummary>();
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
try
{
return Array.Empty<GmailMessageSummary>();
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var results = new List<GmailMessageSummary>();
foreach (var messageElement in messagesElement.EnumerateArray())
{
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/threads/{Uri.EscapeDataString(threadId.Trim())}?format=metadata&metadataHeaders=Subject&metadataHeaders=From&metadataHeaders=To&metadataHeaders=Date";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
? messageThreadIdEl.GetString() ?? threadId.Trim()
: threadId.Trim();
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
DateTimeOffset? date = null;
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("messages", out var messagesElement) || messagesElement.ValueKind != JsonValueKind.Array)
{
date = parsedDate;
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return Array.Empty<GmailMessageSummary>();
}
results.Add(new GmailMessageSummary(
id.Trim(),
messageThreadId,
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
headers.TryGetValue("from", out var from) ? from : string.Empty,
headers.TryGetValue("to", out var to) ? to : string.Empty,
date,
snippet));
}
var results = new List<GmailMessageSummary>();
foreach (var messageElement in messagesElement.EnumerateArray())
{
var id = messageElement.TryGetProperty("id", out var idEl) ? idEl.GetString() : null;
if (string.IsNullOrWhiteSpace(id)) continue;
await TouchSyncTimeAsync(ownerUserId, cancellationToken);
return results;
var messageThreadId = messageElement.TryGetProperty("threadId", out var messageThreadIdEl)
? messageThreadIdEl.GetString() ?? threadId.Trim()
: threadId.Trim();
var snippet = messageElement.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? string.Empty : string.Empty;
var payload = messageElement.TryGetProperty("payload", out var payloadEl) ? payloadEl : default;
var headers = payload.ValueKind == JsonValueKind.Object ? ReadHeaders(payload) : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
DateTimeOffset? date = null;
if (headers.TryGetValue("date", out var dateHeader) && DateTimeOffset.TryParse(dateHeader, out var parsedDate))
{
date = parsedDate;
}
results.Add(new GmailMessageSummary(
id.Trim(),
messageThreadId,
headers.TryGetValue("subject", out var subject) ? subject : string.Empty,
headers.TryGetValue("from", out var from) ? from : string.Empty,
headers.TryGetValue("to", out var to) ? to : string.Empty,
date,
snippet));
}
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "thread-metadata", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
try
{
bodyText = StripHtml(bodyHtml);
}
else if (LooksLikeHtml(bodyText))
{
bodyText = StripHtml(bodyText);
}
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
);
var url = $"https://gmail.googleapis.com/gmail/v1/users/me/messages/{Uri.EscapeDataString(messageId)}?format=full";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var threadId = root.TryGetProperty("threadId", out var threadEl) ? threadEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("snippet", out var snippetEl) ? snippetEl.GetString() ?? "" : "";
var payload = root.GetProperty("payload");
var headers = ReadHeaders(payload);
var bodyText = ExtractBody(payload, "text/plain");
var bodyHtml = ExtractBody(payload, "text/html");
if (string.IsNullOrWhiteSpace(bodyText) && !string.IsNullOrWhiteSpace(bodyHtml))
{
bodyText = StripHtml(bodyHtml);
}
else if (LooksLikeHtml(bodyText))
{
bodyText = StripHtml(bodyText);
}
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", true, null, cancellationToken);
return new GmailMessageDetail(
messageId,
threadId,
headers.TryGetValue("subject", out var subject) ? subject : "",
headers.TryGetValue("from", out var from) ? from : "",
headers.TryGetValue("to", out var to) ? to : "",
headers.TryGetValue("date", out var dateRaw) && DateTimeOffset.TryParse(dateRaw, out var parsedDate) ? parsedDate : null,
snippet,
bodyText.Trim(),
string.IsNullOrWhiteSpace(bodyHtml) ? null : bodyHtml
);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "gmail-message", false, ex.Message, cancellationToken);
throw;
}
}
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
@@ -435,13 +468,37 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
private async Task TouchSyncTimeAsync(string ownerUserId, CancellationToken cancellationToken)
{
await TouchSyncStateAsync(ownerUserId, "sync", "gmail", true, null, cancellationToken);
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.GmailConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncedAt = DateTimeOffset.UtcNow;
var now = DateTimeOffset.UtcNow;
connection.LastSyncAttemptedAt = now;
connection.LastSyncMode = mode;
connection.LastSyncSource = source;
connection.LastSyncStatus = succeeded ? "success" : "error";
connection.LastSyncError = succeeded ? null : TrimError(error);
if (succeeded)
{
connection.LastSyncedAt = now;
connection.LastSyncSucceededAt = now;
}
await _db.SaveChangesAsync(cancellationToken);
}
private static string? TrimError(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Trim();
return trimmed.Length <= 300 ? trimmed : trimmed[..300];
}
private string GetRequiredClientId()
{
return (_cfg["Google:ClientId"] ?? _cfg["Auth:GoogleClientId"] ?? "").Trim() switch