feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped

b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:

- MicrosoftGraphConnection model + table (reconciler pattern, SQLite+MySQL,
  same shape as GmailConnection: encrypted refresh/access token, sync state).
- MicrosoftGraphOAuthService: auth-code + offline-access flow against
  login.microsoftonline.com, encrypted token storage via IDataProtector,
  message search/thread/detail fetch against Microsoft Graph (conversationId
  stands in for Gmail's threadId), attachment listing.
- MicrosoftGraphProvider implements IEmailProvider — no contract changes;
  the existing seam was already provider-neutral.
- MicrosoftGraphController: connect-url/oauth/callback/status/disconnect,
  mirrors GmailController's OAuth surface exactly (including the popup
  postMessage handshake). Job-matching/review endpoints stay Gmail-only for
  now, per the roadmap — generalising those needs the frontend provider
  picker work, not this slice.
- Registered in DI + IEmailProviderRegistry (multi-registration of
  IEmailProvider, resolved by ProviderKey).
- Config: Microsoft:ClientId/ClientSecret/TenantId/RedirectUri, wired through
  docker-compose.yml + .env.example alongside the existing Google:Gmail* keys.
- Tests: MicrosoftGraphControllerTests (OAuth lifecycle) +
  MicrosoftGraphProviderTests (DTO mapping onto the neutral contract).
  147/147 green (135 existing + 12 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cesnimda
2026-07-11 18:08:11 +02:00
parent 7529b99edd
commit cacad5cc94
11 changed files with 1060 additions and 1 deletions
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Outlook / Microsoft 365 implementation of <see cref="IEmailProvider"/>. Adapts
/// <see cref="IMicrosoftGraphOAuthService"/> (Microsoft Graph client) to the provider-neutral
/// contract, mapping Graph DTOs to the neutral shapes.
/// </summary>
public sealed class MicrosoftGraphProvider : IEmailProvider
{
private readonly IMicrosoftGraphOAuthService _graph;
public MicrosoftGraphProvider(IMicrosoftGraphOAuthService graph)
{
_graph = graph;
}
public string ProviderKey => "microsoft";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _graph.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _graph.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _graph.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GraphAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ConversationId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
@@ -0,0 +1,507 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IMicrosoftGraphOAuthService
{
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
string? ConsumeState(string state);
Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline);
public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<MicrosoftGraphMessageAttachment> Attachments);
internal sealed class MicrosoftGraphTokenResponse
{
public string? access_token { get; set; }
public int expires_in { get; set; }
public string? refresh_token { get; set; }
public string? scope { get; set; }
public string? token_type { get; set; }
}
/// <summary>
/// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors <see cref="GmailOAuthService"/>'s shape
/// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers
/// stay structurally interchangeable behind <see cref="EmailProviders.IEmailProvider"/>.
/// </summary>
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
{
private const string Scope = "openid email profile offline_access https://graph.microsoft.com/Mail.Read";
private readonly IConfiguration _cfg;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
public MicrosoftGraphOAuthService(
IConfiguration cfg,
JobTrackerContext db,
IDataProtectionProvider protectionProvider,
IHttpClientFactory httpClientFactory,
IMemoryCache cache)
{
_cfg = cfg;
_db = db;
_protector = protectionProvider.CreateProtector("microsoft-graph-oauth-tokens-v1");
_httpClientFactory = httpClientFactory;
_cache = cache;
}
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
{
var clientId = GetRequiredClientId();
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
var query = new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["response_mode"] = "query",
["scope"] = Scope,
["state"] = state,
};
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
return $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/authorize?{encoded}";
}
public async Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
{
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
var accessToken = tokens.access_token?.Trim();
var refreshToken = tokens.refresh_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Microsoft did not return an access token.");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Microsoft did not return a refresh token. Reconnect Outlook and ensure offline_access consent is granted.");
var mailAddress = await GetProfileAsync(accessToken, cancellationToken);
if (string.IsNullOrWhiteSpace(mailAddress))
throw new InvalidOperationException("Microsoft did not return a mail address.");
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new MicrosoftGraphConnection
{
OwnerUserId = ownerUserId,
};
_db.MicrosoftGraphConnections.Add(existing);
}
existing.MailAddress = mailAddress.Trim();
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
existing.EncryptedAccessToken = _protector.Protect(accessToken);
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 MicrosoftGraphOAuthExchangeResult(existing.MailAddress);
}
public string? ConsumeState(string state)
{
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
{
return null;
}
_cache.Remove(GetStateCacheKey(state));
return ownerUserId;
}
public Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
return _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
}
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
{
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null) return;
_db.MicrosoftGraphConnections.Remove(existing);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
var url = $"https://graph.microsoft.com/v1.0/me/messages?$top={maxResults}&$select={select}&$orderby=receivedDateTime desc";
if (!string.IsNullOrWhiteSpace(query))
{
client.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
url += $"&$search={Uri.EscapeDataString(EscapeSearchQuery(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);
var results = ReadMessageSummaries(doc.RootElement);
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(conversationId))
{
return Array.Empty<MicrosoftGraphMessageSummary>();
}
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
var filter = $"conversationId eq '{conversationId.Trim().Replace("'", "''")}'";
var url = $"https://graph.microsoft.com/v1.0/me/messages?$filter={Uri.EscapeDataString(filter)}&$select={select}&$orderby=receivedDateTime asc";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var results = ReadMessageSummaries(doc.RootElement);
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,categories,hasAttachments";
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}?$select={select}";
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 conversationId = root.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : "";
var categories = root.TryGetProperty("categories", out var categoriesEl) && categoriesEl.ValueKind == JsonValueKind.Array
? categoriesEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: new List<string>();
string bodyText = "";
string? bodyHtml = null;
if (root.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object)
{
var contentType = bodyEl.TryGetProperty("contentType", out var ctEl) ? ctEl.GetString() ?? "" : "";
var content = bodyEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
if (string.Equals(contentType, "html", StringComparison.OrdinalIgnoreCase))
{
bodyHtml = content;
bodyText = StripHtml(content);
}
else
{
bodyText = content;
}
}
var attachments = root.TryGetProperty("hasAttachments", out var hasAttEl) && hasAttEl.ValueKind == JsonValueKind.True
? await ListAttachmentsAsync(client, messageId, cancellationToken)
: Array.Empty<MicrosoftGraphMessageAttachment>();
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", true, null, cancellationToken);
return new MicrosoftGraphMessageDetail(
messageId,
conversationId,
ReadSubject(root),
ReadRecipientAddress(root, "from"),
ReadRecipientListAddresses(root, "toRecipients"),
ReadReceivedDate(root),
snippet,
bodyText.Trim(),
bodyHtml,
categories,
attachments);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", false, ex.Message, cancellationToken);
throw;
}
}
private static async Task<IReadOnlyList<MicrosoftGraphMessageAttachment>> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken)
{
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return Array.Empty<MicrosoftGraphMessageAttachment>();
}
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
{
return Array.Empty<MicrosoftGraphMessageAttachment>();
}
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageAttachment(
item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
item.TryGetProperty("contentType", out var typeEl) ? typeEl.GetString() : null,
item.TryGetProperty("size", out var sizeEl) && sizeEl.TryGetInt64(out var size) ? size : null,
item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null,
item.TryGetProperty("isInline", out var inlineEl) && inlineEl.ValueKind == JsonValueKind.True
)).ToList();
}
private static List<MicrosoftGraphMessageSummary> ReadMessageSummaries(JsonElement root)
{
if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
{
return new List<MicrosoftGraphMessageSummary>();
}
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageSummary(
item.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "",
item.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "",
ReadSubject(item),
ReadRecipientAddress(item, "from"),
ReadRecipientListAddresses(item, "toRecipients"),
ReadReceivedDate(item),
item.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : ""
)).Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToList();
}
private static string ReadSubject(JsonElement item)
=> item.TryGetProperty("subject", out var subjectEl) ? subjectEl.GetString() ?? "" : "";
private static DateTimeOffset? ReadReceivedDate(JsonElement item)
=> item.TryGetProperty("receivedDateTime", out var dateEl) && dateEl.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(dateEl.GetString(), out var parsed) ? parsed : null;
private static string ReadRecipientAddress(JsonElement item, string property)
{
if (!item.TryGetProperty(property, out var recipientEl) || recipientEl.ValueKind != JsonValueKind.Object) return "";
if (!recipientEl.TryGetProperty("emailAddress", out var addressEl) || addressEl.ValueKind != JsonValueKind.Object) return "";
return addressEl.TryGetProperty("address", out var addrEl) ? addrEl.GetString() ?? "" : "";
}
private static string ReadRecipientListAddresses(JsonElement item, string property)
{
if (!item.TryGetProperty(property, out var listEl) || listEl.ValueKind != JsonValueKind.Array) return "";
var addresses = listEl.EnumerateArray()
.Where(r => r.TryGetProperty("emailAddress", out _))
.Select(r => r.GetProperty("emailAddress").TryGetProperty("address", out var a) ? a.GetString() : null)
.Where(a => !string.IsNullOrWhiteSpace(a));
return string.Join(", ", addresses);
}
private static string EscapeSearchQuery(string query) => "\"" + query.Replace("\"", "'") + "\"";
private static string StripHtml(string html)
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null)
throw new InvalidOperationException("Outlook is not connected for this account.");
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
connection.AccessTokenExpiresAt is { } expiresAt &&
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
{
try
{
return _protector.Unprotect(connection.EncryptedAccessToken);
}
catch (CryptographicException)
{
connection.EncryptedAccessToken = null;
connection.AccessTokenExpiresAt = null;
}
}
string refreshToken;
try
{
refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
}
catch (CryptographicException)
{
throw new InvalidOperationException("Your stored Outlook connection can no longer be decrypted after a server key change. Disconnect Outlook and connect it again.");
}
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
var accessToken = refreshed.access_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to refresh Outlook access token.");
connection.EncryptedAccessToken = _protector.Protect(accessToken);
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
if (!string.IsNullOrWhiteSpace(refreshed.scope))
{
connection.Scope = refreshed.scope.Trim();
}
if (!string.IsNullOrWhiteSpace(refreshed.refresh_token))
{
connection.EncryptedRefreshToken = _protector.Protect(refreshed.refresh_token.Trim());
}
await _db.SaveChangesAsync(cancellationToken);
return accessToken;
}
private async Task<MicrosoftGraphTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["redirect_uri"] = redirectUri,
["grant_type"] = "authorization_code",
["scope"] = Scope,
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Microsoft token exchange failed: {payload}");
}
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Microsoft token response.");
}
private async Task<MicrosoftGraphTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["refresh_token"] = refreshToken,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["grant_type"] = "refresh_token",
["scope"] = Scope,
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Microsoft token refresh failed: {payload}");
}
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Microsoft refresh response.");
}
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.GetAsync("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to read Microsoft profile: {payload}");
}
using var doc = JsonDocument.Parse(payload);
var mail = doc.RootElement.TryGetProperty("mail", out var mailEl) ? mailEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(mail)) return mail;
return doc.RootElement.TryGetProperty("userPrincipalName", out var upnEl) ? upnEl.GetString() ?? "" : "";
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
connection.LastSyncMode = mode;
connection.LastSyncSource = source;
connection.LastSyncStatus = succeeded ? "ok" : "error";
connection.LastSyncError = succeeded ? null : error;
if (succeeded)
{
connection.LastSyncedAt = DateTimeOffset.UtcNow;
connection.LastSyncSucceededAt = connection.LastSyncedAt;
}
await _db.SaveChangesAsync(cancellationToken);
}
private string GetTenant() => (_cfg["Microsoft:TenantId"] ?? "common").Trim();
private string GetRequiredClientId()
{
var value = (_cfg["Microsoft:ClientId"] ?? "").Trim();
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientId is not configured.");
}
private string GetRequiredClientSecret()
{
var value = (_cfg["Microsoft:ClientSecret"] ?? "").Trim();
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientSecret is not configured.");
}
private static string GetStateCacheKey(string state) => $"microsoft-oauth-state:{state}";
}
@@ -360,6 +360,32 @@ public static class StartupInitializationExtensions
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_GmailConnections_OwnerUserId_GmailAddress" ON "GmailConnections" ("OwnerUserId", "GmailAddress");""");
}
static void EnsureMicrosoftGraphConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "MicrosoftGraphConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_MicrosoftGraphConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"MailAddress" TEXT NOT NULL,
"EncryptedRefreshToken" TEXT NOT NULL,
"EncryptedAccessToken" TEXT NULL,
"AccessTokenExpiresAt" TEXT NULL,
"Scope" TEXT NOT NULL,
"ConnectedAt" TEXT NOT NULL,
"LastSyncedAt" TEXT NULL,
"LastSyncAttemptedAt" TEXT NULL,
"LastSyncSucceededAt" TEXT NULL,
"LastSyncMode" TEXT NULL,
"LastSyncSource" TEXT NULL,
"LastSyncStatus" TEXT NULL,
"LastSyncError" TEXT NULL
);
""");
Exec(c, """CREATE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId" ON "MicrosoftGraphConnections" ("OwnerUserId");""");
Exec(c, """CREATE UNIQUE INDEX IF NOT EXISTS "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress" ON "MicrosoftGraphConnections" ("OwnerUserId", "MailAddress");""");
}
static void EnsureCvTables(DbConnection c)
{
Exec(c, """
@@ -428,6 +454,7 @@ public static class StartupInitializationExtensions
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureCvTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
@@ -606,6 +633,7 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
@@ -799,6 +827,30 @@ public static class StartupInitializationExtensions
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, "MicrosoftGraphConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `MicrosoftGraphConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`MailAddress` 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();
}
if (!HasMySqlTable(conn, "TailoredCvDrafts"))
{
using var cmd = conn.CreateCommand();
@@ -890,6 +942,20 @@ public static class StartupInitializationExtensions
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE INDEX `IX_MicrosoftGraphConnections_OwnerUserId` ON `MicrosoftGraphConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "MicrosoftGraphConnections", "IX_MicrosoftGraphConnections_OwnerUserId_MailAddress"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_MicrosoftGraphConnections_OwnerUserId_MailAddress` ON `MicrosoftGraphConnections` (`OwnerUserId`, `MailAddress`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();