feat: harden gmail sync foundation
This commit is contained in:
@@ -68,18 +68,34 @@ public sealed class GmailController : ControllerBase
|
||||
int CandidateThreadCount,
|
||||
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
|
||||
|
||||
public sealed record GmailConnectionStatusDto(
|
||||
bool Connected,
|
||||
string? GmailAddress,
|
||||
DateTimeOffset? ConnectedAt,
|
||||
DateTimeOffset? LastSyncedAt,
|
||||
DateTimeOffset? LastSyncAttemptedAt,
|
||||
DateTimeOffset? LastSyncSucceededAt,
|
||||
string? LastSyncMode,
|
||||
string? LastSyncSource,
|
||||
string? LastSyncStatus,
|
||||
string? LastSyncError);
|
||||
|
||||
[HttpGet("status")]
|
||||
public async Task<IActionResult> Status(CancellationToken cancellationToken)
|
||||
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
|
||||
{
|
||||
var ownerUserId = GetRequiredOwnerUserId();
|
||||
var connection = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
|
||||
return Ok(new
|
||||
{
|
||||
connected = connection is not null,
|
||||
gmailAddress = connection?.GmailAddress,
|
||||
connectedAt = connection?.ConnectedAt,
|
||||
lastSyncedAt = connection?.LastSyncedAt,
|
||||
});
|
||||
return Ok(new GmailConnectionStatusDto(
|
||||
connection is not null,
|
||||
connection?.GmailAddress,
|
||||
connection?.ConnectedAt,
|
||||
connection?.LastSyncedAt,
|
||||
connection?.LastSyncAttemptedAt,
|
||||
connection?.LastSyncSucceededAt,
|
||||
connection?.LastSyncMode,
|
||||
connection?.LastSyncSource,
|
||||
connection?.LastSyncStatus,
|
||||
connection?.LastSyncError));
|
||||
}
|
||||
|
||||
[HttpGet("connect-url")]
|
||||
|
||||
@@ -135,6 +135,7 @@ builder.Services.AddSingleton<ISummarizerService, SummarizerService>();
|
||||
builder.Services.AddSingleton<ICvAiClassifier, CvAiClassifier>();
|
||||
builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
|
||||
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
|
||||
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
|
||||
|
||||
builder.Services.AddIdentityCore<ApplicationUser>(options =>
|
||||
{
|
||||
@@ -628,10 +629,23 @@ CREATE TABLE IF NOT EXISTS "GmailConnections" (
|
||||
"AccessTokenExpiresAt" TEXT NULL,
|
||||
"Scope" TEXT NOT NULL,
|
||||
"ConnectedAt" TEXT NOT NULL,
|
||||
"LastSyncedAt" TEXT NULL
|
||||
"LastSyncedAt" TEXT NULL,
|
||||
"LastSyncAttemptedAt" TEXT NULL,
|
||||
"LastSyncSucceededAt" TEXT NULL,
|
||||
"LastSyncMode" TEXT NULL,
|
||||
"LastSyncSource" TEXT NULL,
|
||||
"LastSyncStatus" TEXT NULL,
|
||||
"LastSyncError" TEXT NULL
|
||||
);
|
||||
""");
|
||||
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncAttemptedAt TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSucceededAt TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncMode", "ALTER TABLE GmailConnections ADD COLUMN LastSyncMode TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncSource", "ALTER TABLE GmailConnections ADD COLUMN LastSyncSource TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncStatus", "ALTER TABLE GmailConnections ADD COLUMN LastSyncStatus TEXT NULL;");
|
||||
EnsureColumn(c, "GmailConnections", "LastSyncError", "ALTER TABLE GmailConnections ADD COLUMN LastSyncError TEXT NULL;");
|
||||
|
||||
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId" ON "GmailConnections" ("OwnerUserId");""");
|
||||
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
|
||||
}
|
||||
@@ -939,6 +953,37 @@ CONSTRAINT `FK_CvExtractionRuns_CvUploadArtifacts_ArtifactId` FOREIGN KEY (`Arti
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!HasMySqlTable(conn, "GmailConnections"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `GmailConnections` (
|
||||
`Id` int NOT NULL AUTO_INCREMENT,
|
||||
`OwnerUserId` varchar(255) NOT NULL,
|
||||
`GmailAddress` varchar(512) NOT NULL,
|
||||
`EncryptedRefreshToken` longtext NOT NULL,
|
||||
`EncryptedAccessToken` longtext NULL,
|
||||
`AccessTokenExpiresAt` datetime(6) NULL,
|
||||
`Scope` longtext NOT NULL,
|
||||
`ConnectedAt` datetime(6) NOT NULL,
|
||||
`LastSyncedAt` datetime(6) NULL,
|
||||
`LastSyncAttemptedAt` datetime(6) NULL,
|
||||
`LastSyncSucceededAt` datetime(6) NULL,
|
||||
`LastSyncMode` varchar(255) NULL,
|
||||
`LastSyncSource` varchar(255) NULL,
|
||||
`LastSyncStatus` varchar(255) NULL,
|
||||
`LastSyncError` longtext NULL,
|
||||
PRIMARY KEY (`Id`)
|
||||
);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncAttemptedAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncAttemptedAt` datetime(6) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSucceededAt", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSucceededAt` datetime(6) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncMode", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncMode` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncSource", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncSource` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncStatus", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncStatus` varchar(255) NULL;");
|
||||
EnsureMySqlColumn(conn, "GmailConnections", "LastSyncError", "ALTER TABLE `GmailConnections` ADD COLUMN `LastSyncError` longtext NULL;");
|
||||
|
||||
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
@@ -1000,6 +1045,20 @@ CONSTRAINT `FK_TailoredCvDrafts_JobApplications_JobApplicationId` FOREIGN KEY (`
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE INDEX `IX_GmailConnections_OwnerUserId` ON `GmailConnections` (`OwnerUserId`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "GmailConnections", "IX_GmailConnections_OwnerUserId_GmailAddress"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "CREATE UNIQUE INDEX `IX_GmailConnections_OwnerUserId_GmailAddress` ON `GmailConnections` (`OwnerUserId`, `GmailAddress`);";
|
||||
cmd.ExecuteNonQuery();
|
||||
}
|
||||
|
||||
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
|
||||
{
|
||||
using var cmd = conn.CreateCommand();
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
namespace JobTrackerApi.Services;
|
||||
|
||||
public sealed record GmailSemanticMatchCandidate(
|
||||
int? JobApplicationId,
|
||||
string? Confidence,
|
||||
string? Reason,
|
||||
IReadOnlyList<string>? ExtractedCompanies,
|
||||
IReadOnlyList<string>? ExtractedRecruiters,
|
||||
IReadOnlyList<string>? ExtractedRoles,
|
||||
IReadOnlyList<string>? ExtractedHints);
|
||||
|
||||
public interface IGmailCorrespondenceEnrichmentService
|
||||
{
|
||||
Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
public sealed class NoOpGmailCorrespondenceEnrichmentService : IGmailCorrespondenceEnrichmentService
|
||||
{
|
||||
public Task<GmailSemanticMatchCandidate?> EnrichAsync(string threadSubject, string from, string to, string snippet, string? bodyText, CancellationToken cancellationToken = default)
|
||||
=> Task.FromResult<GmailSemanticMatchCandidate?>(null);
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user