diff --git a/.env.example b/.env.example index 2546581..eff4d95 100644 --- a/.env.example +++ b/.env.example @@ -8,6 +8,12 @@ AUTH_GOOGLE_CLIENT_ID=CHANGE_ME_GOOGLE_CLIENT_ID GOOGLE_GMAIL_CLIENT_SECRET=CHANGE_ME_GOOGLE_OAUTH_CLIENT_SECRET # Optional. If omitted, the backend uses https:///api/gmail/oauth/callback GOOGLE_GMAIL_REDIRECT_URI= +MICROSOFT_CLIENT_ID=CHANGE_ME_MICROSOFT_CLIENT_ID +MICROSOFT_CLIENT_SECRET=CHANGE_ME_MICROSOFT_OAUTH_CLIENT_SECRET +# Optional. Defaults to "common" (personal + work/school accounts). +MICROSOFT_TENANT_ID= +# Optional. If omitted, the backend uses https:///api/microsoft-graph/oauth/callback +MICROSOFT_REDIRECT_URI= AI_SERVICE_BASE_URL=http://ai-service:8001 # Optional: enables hybrid CV block classification in the local AI service. OLLAMA_BASE_URL=http://ollama:11434 diff --git a/Data/JobTrackerContext.cs b/Data/JobTrackerContext.cs index b7c7f07..8e4025f 100644 --- a/Data/JobTrackerContext.cs +++ b/Data/JobTrackerContext.cs @@ -18,6 +18,7 @@ namespace JobTrackerApi.Data public DbSet Correspondences => Set(); public DbSet GmailConnections => Set(); public DbSet GmailReviewDecisions => Set(); + public DbSet MicrosoftGraphConnections => Set(); public DbSet Attachments => Set(); public DbSet RuleSettings => Set(); public DbSet UserRuleSettings => Set(); diff --git a/JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs b/JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs new file mode 100644 index 0000000..c6e58f4 --- /dev/null +++ b/JobTrackerApi.Tests/MicrosoftGraphControllerTests.cs @@ -0,0 +1,157 @@ +using System.Security.Claims; +using JobTrackerApi.Controllers; +using JobTrackerApi.Data; +using JobTrackerApi.Models; +using JobTrackerApi.Services; +using JobTrackerApi.Tests.TestSupport; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class MicrosoftGraphControllerTests +{ + [Fact] + public async Task Status_returns_sync_state_fields_for_connected_account() + { + var graph = new Mock(); + graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new MicrosoftGraphConnection + { + OwnerUserId = "user-1", + MailAddress = "user@outlook.test", + ConnectedAt = DateTimeOffset.UtcNow.AddDays(-2), + LastSyncedAt = DateTimeOffset.UtcNow.AddMinutes(-10), + LastSyncAttemptedAt = DateTimeOffset.UtcNow.AddMinutes(-5), + LastSyncSucceededAt = DateTimeOffset.UtcNow.AddMinutes(-10), + LastSyncMode = "list-messages", + LastSyncSource = "custom-query", + LastSyncStatus = "error", + LastSyncError = "Token refresh failed" + }); + + var controller = CreateController(graph.Object, "user-1"); + var result = await controller.Status(CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.True(payload.Connected); + Assert.Equal("user@outlook.test", payload.MailAddress); + Assert.Equal("list-messages", payload.LastSyncMode); + Assert.Equal("custom-query", payload.LastSyncSource); + Assert.Equal("error", payload.LastSyncStatus); + Assert.Equal("Token refresh failed", payload.LastSyncError); + } + + [Fact] + public async Task Status_reports_not_connected_when_no_connection_exists() + { + var graph = new Mock(); + graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync((MicrosoftGraphConnection?)null); + + var controller = CreateController(graph.Object, "user-1"); + var result = await controller.Status(CancellationToken.None); + + var ok = Assert.IsType(result.Result); + var payload = Assert.IsType(ok.Value); + Assert.False(payload.Connected); + Assert.Null(payload.MailAddress); + } + + [Fact] + public void ConnectUrl_returns_authorization_url_from_service() + { + var graph = new Mock(); + graph.Setup(service => service.BuildAuthorizationUrl("user-1", It.IsAny())) + .Returns("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test"); + + var controller = CreateController(graph.Object, "user-1"); + var result = controller.ConnectUrl(); + + var ok = Assert.IsType(result); + var url = ok.Value!.GetType().GetProperty("url")!.GetValue(ok.Value) as string; + Assert.Equal("https://login.microsoftonline.com/common/oauth2/v2.0/authorize?client_id=test", url); + } + + [Fact] + public async Task Callback_returns_error_html_when_state_is_invalid() + { + var graph = new Mock(); + graph.Setup(service => service.ConsumeState("bad-state")).Returns((string?)null); + + var controller = CreateController(graph.Object, "user-1"); + var result = await controller.Callback("auth-code", "bad-state", null, CancellationToken.None); + + var content = Assert.IsType(result); + Assert.Contains("no longer valid", content.Content); + graph.Verify(service => service.ExchangeCodeAsync(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task Callback_returns_error_html_when_provider_returns_error() + { + var graph = new Mock(MockBehavior.Strict); + var controller = CreateController(graph.Object, "user-1"); + + var result = await controller.Callback(null, null, "access_denied", CancellationToken.None); + + var content = Assert.IsType(result); + Assert.Contains("access_denied", content.Content); + } + + [Fact] + public async Task Callback_exchanges_code_and_reports_connected_mail_address() + { + var graph = new Mock(); + graph.Setup(service => service.ConsumeState("good-state")).Returns("user-1"); + graph.Setup(service => service.ExchangeCodeAsync("user-1", "auth-code", It.IsAny(), It.IsAny())) + .ReturnsAsync(new MicrosoftGraphOAuthExchangeResult("user@outlook.test")); + + var controller = CreateController(graph.Object, "user-1"); + var result = await controller.Callback("auth-code", "good-state", null, CancellationToken.None); + + var content = Assert.IsType(result); + Assert.Contains("user@outlook.test", content.Content); + } + + [Fact] + public async Task Disconnect_calls_service_for_authenticated_user() + { + var graph = new Mock(); + graph.Setup(service => service.DisconnectAsync("user-1", It.IsAny())).Returns(Task.CompletedTask); + + var controller = CreateController(graph.Object, "user-1"); + var result = await controller.Disconnect(CancellationToken.None); + + Assert.IsType(result); + graph.Verify(service => service.DisconnectAsync("user-1", It.IsAny()), Times.Once); + } + + private static MicrosoftGraphController CreateController(IMicrosoftGraphOAuthService graph, string userId) + { + return new MicrosoftGraphController(graph, BuildConfig()) + { + ControllerContext = new ControllerContext + { + HttpContext = new DefaultHttpContext + { + User = new ClaimsPrincipal(new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, userId) + }, "test")) + } + } + }; + } + + private static IConfiguration BuildConfig() + { + return new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + } +} diff --git a/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs b/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs new file mode 100644 index 0000000..41ab0f7 --- /dev/null +++ b/JobTrackerApi.Tests/MicrosoftGraphProviderTests.cs @@ -0,0 +1,82 @@ +using JobTrackerApi.Services; +using JobTrackerApi.Services.EmailProviders; +using Moq; +using Xunit; + +namespace JobTrackerApi.Tests; + +public sealed class MicrosoftGraphProviderTests +{ + [Fact] + public void ProviderKey_is_microsoft() + { + var provider = new MicrosoftGraphProvider(Mock.Of()); + Assert.Equal("microsoft", provider.ProviderKey); + } + + [Fact] + public async Task GetConnectionAsync_maps_mail_address_onto_neutral_shape() + { + var graph = new Mock(); + graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync(new JobTrackerApi.Models.MicrosoftGraphConnection { OwnerUserId = "user-1", MailAddress = "user@outlook.test" }); + + var provider = new MicrosoftGraphProvider(graph.Object); + var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None); + + Assert.NotNull(connection); + Assert.Equal("microsoft", connection!.ProviderKey); + Assert.Equal("user@outlook.test", connection.Address); + } + + [Fact] + public async Task GetConnectionAsync_returns_null_when_not_connected() + { + var graph = new Mock(); + graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny())) + .ReturnsAsync((JobTrackerApi.Models.MicrosoftGraphConnection?)null); + + var provider = new MicrosoftGraphProvider(graph.Object); + var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None); + + Assert.Null(connection); + } + + [Fact] + public async Task SearchAsync_maps_conversation_id_onto_neutral_thread_id() + { + var graph = new Mock(); + graph.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny())) + .ReturnsAsync(new List + { + new("msg-1", "conv-1", "Interview", "them@company.test", "me@outlook.test", DateTimeOffset.UtcNow, "snippet") + }); + + var provider = new MicrosoftGraphProvider(graph.Object); + var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None); + + var summary = Assert.Single(results); + Assert.Equal("msg-1", summary.Id); + Assert.Equal("conv-1", summary.ThreadId); + Assert.Equal("Interview", summary.Subject); + } + + [Fact] + public async Task GetMessageAsync_maps_attachment_id_onto_neutral_external_attachment_id() + { + var graph = new Mock(); + graph.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny())) + .ReturnsAsync(new MicrosoftGraphMessageDetail( + "msg-1", "conv-1", "Offer", "them@company.test", "me@outlook.test", DateTimeOffset.UtcNow, "snippet", + "body text", "

body

", new List { "Inbox" }, + new List { new("resume.pdf", "application/pdf", 1024, "graph-att-1", false) })); + + var provider = new MicrosoftGraphProvider(graph.Object); + var detail = await provider.GetMessageAsync("user-1", "msg-1", CancellationToken.None); + + Assert.Equal("conv-1", detail.ThreadId); + var attachment = Assert.Single(detail.Attachments); + Assert.Equal("resume.pdf", attachment.FileName); + Assert.Equal("graph-att-1", attachment.ExternalAttachmentId); + } +} diff --git a/JobTrackerApi/Controllers/MicrosoftGraphController.cs b/JobTrackerApi/Controllers/MicrosoftGraphController.cs new file mode 100644 index 0000000..37bdf09 --- /dev/null +++ b/JobTrackerApi/Controllers/MicrosoftGraphController.cs @@ -0,0 +1,150 @@ +using System.Security.Claims; +using JobTrackerApi.Services; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace JobTrackerApi.Controllers; + +/// +/// Outlook / Microsoft 365 connection lifecycle (connect, OAuth callback, status, disconnect). +/// Mirrors the Gmail OAuth surface in . Message search/import runs +/// through the provider-neutral IEmailProvider seam once a job's correspondence flow is +/// generalised past Gmail; this controller only owns the Microsoft-specific connection lifecycle. +/// +[ApiController] +[Route("api/microsoft-graph")] +[Authorize] +public sealed class MicrosoftGraphController : ControllerBase +{ + private readonly IMicrosoftGraphOAuthService _graph; + private readonly IConfiguration _cfg; + + public MicrosoftGraphController(IMicrosoftGraphOAuthService graph, IConfiguration cfg) + { + _graph = graph; + _cfg = cfg; + } + + public sealed record MicrosoftGraphConnectionStatusDto( + bool Connected, + string? MailAddress, + DateTimeOffset? ConnectedAt, + DateTimeOffset? LastSyncedAt, + DateTimeOffset? LastSyncAttemptedAt, + DateTimeOffset? LastSyncSucceededAt, + string? LastSyncMode, + string? LastSyncSource, + string? LastSyncStatus, + string? LastSyncError); + + [HttpGet("status")] + public async Task> Status(CancellationToken cancellationToken) + { + var ownerUserId = GetRequiredOwnerUserId(); + var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken); + return Ok(new MicrosoftGraphConnectionStatusDto( + connection is not null, + connection?.MailAddress, + connection?.ConnectedAt, + connection?.LastSyncedAt, + connection?.LastSyncAttemptedAt, + connection?.LastSyncSucceededAt, + connection?.LastSyncMode, + connection?.LastSyncSource, + connection?.LastSyncStatus, + connection?.LastSyncError)); + } + + [HttpGet("connect-url")] + public IActionResult ConnectUrl() + { + var ownerUserId = GetRequiredOwnerUserId(); + var url = _graph.BuildAuthorizationUrl(ownerUserId, GetRedirectUri()); + return Ok(new { url }); + } + + [AllowAnonymous] + [HttpGet("oauth/callback")] + public async Task Callback([FromQuery] string? code, [FromQuery] string? state, [FromQuery] string? error, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(error)) + { + return Content(BuildPopupHtml(false, $"Microsoft returned an error: {error}"), "text/html"); + } + + if (string.IsNullOrWhiteSpace(code) || string.IsNullOrWhiteSpace(state)) + { + return Content(BuildPopupHtml(false, "Missing Microsoft OAuth code or state."), "text/html"); + } + + var ownerUserId = _graph.ConsumeState(state); + if (string.IsNullOrWhiteSpace(ownerUserId)) + { + return Content(BuildPopupHtml(false, "This Outlook connection request is no longer valid. Start the connection again."), "text/html"); + } + + try + { + var result = await _graph.ExchangeCodeAsync(ownerUserId, code, GetRedirectUri(), cancellationToken); + return Content(BuildPopupHtml(true, $"Connected Outlook: {result.MailAddress}"), "text/html"); + } + catch (Exception ex) + { + return Content(BuildPopupHtml(false, ex.Message), "text/html"); + } + } + + [HttpDelete("connection")] + public async Task Disconnect(CancellationToken cancellationToken) + { + var ownerUserId = GetRequiredOwnerUserId(); + await _graph.DisconnectAsync(ownerUserId, cancellationToken); + return NoContent(); + } + + private string GetRequiredOwnerUserId() + { + return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub") + ?? throw new InvalidOperationException("Authenticated user id is missing."); + } + + private string GetRedirectUri() + { + var configured = (_cfg["Microsoft:RedirectUri"] ?? "").Trim(); + if (!string.IsNullOrWhiteSpace(configured)) return configured; + + var publicBaseUrl = (_cfg["App:PublicBaseUrl"] ?? "").Trim().TrimEnd('/'); + if (!string.IsNullOrWhiteSpace(publicBaseUrl)) + { + return $"{publicBaseUrl}/api/microsoft-graph/oauth/callback"; + } + + return $"{Request.Scheme}://{Request.Host}/api/microsoft-graph/oauth/callback"; + } + + private static string BuildPopupHtml(bool success, string message) + { + var escaped = System.Net.WebUtility.HtmlEncode(message); + var status = success ? "connected" : "error"; + var title = success ? "Outlook connected" : "Outlook connection failed"; + var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message); + return $@" + + + + Outlook connection + + +

{title}

+

{escaped}

+

You can close this window.

+ + +"; + } +} diff --git a/JobTrackerApi/Program.cs b/JobTrackerApi/Program.cs index 41f3a6a..0cbfd61 100644 --- a/JobTrackerApi/Program.cs +++ b/JobTrackerApi/Program.cs @@ -165,9 +165,11 @@ builder.Services.AddSingleton(); builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddScoped(); -// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next). +// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next). builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddScoped(); builder.Services.AddIdentityCore(options => diff --git a/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs b/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs new file mode 100644 index 0000000..da2ee06 --- /dev/null +++ b/JobTrackerApi/Services/EmailProviders/MicrosoftGraphProvider.cs @@ -0,0 +1,63 @@ +using JobTrackerApi.Services; + +namespace JobTrackerApi.Services.EmailProviders +{ + /// + /// Outlook / Microsoft 365 implementation of . Adapts + /// (Microsoft Graph client) to the provider-neutral + /// contract, mapping Graph DTOs to the neutral shapes. + /// + public sealed class MicrosoftGraphProvider : IEmailProvider + { + private readonly IMicrosoftGraphOAuthService _graph; + + public MicrosoftGraphProvider(IMicrosoftGraphOAuthService graph) + { + _graph = graph; + } + + public string ProviderKey => "microsoft"; + + public async Task 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> 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> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken) + { + var messages = await _graph.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken); + return messages.Select(ToSummary).ToList(); + } + + public async Task 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); + } +} diff --git a/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs new file mode 100644 index 0000000..fc296fb --- /dev/null +++ b/JobTrackerApi/Services/MicrosoftGraphOAuthService.cs @@ -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 ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken); + Task GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken); + Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken); + Task> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken); + Task> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken); + Task 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 Labels, IReadOnlyList 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; } +} + +/// +/// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors 's shape +/// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers +/// stay structurally interchangeable behind . +/// +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 + { + ["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 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(GetStateCacheKey(state), out var ownerUserId)) + { + return null; + } + + _cache.Remove(GetStateCacheKey(state)); + return ownerUserId; + } + + public Task 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> 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> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(conversationId)) + { + return Array.Empty(); + } + + 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 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().ToList() + : new List(); + + 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(); + + 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> 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(); + } + + 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(); + } + + 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 ReadMessageSummaries(JsonElement root) + { + if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array) + { + return new List(); + } + + 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 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 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 + { + ["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(payload) + ?? throw new InvalidOperationException("Unable to parse Microsoft token response."); + } + + private async Task 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 + { + ["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(payload) + ?? throw new InvalidOperationException("Unable to parse Microsoft refresh response."); + } + + private async Task 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}"; +} diff --git a/JobTrackerApi/Services/StartupInitializationExtensions.cs b/JobTrackerApi/Services/StartupInitializationExtensions.cs index 6d8664b..72b81ea 100644 --- a/JobTrackerApi/Services/StartupInitializationExtensions.cs +++ b/JobTrackerApi/Services/StartupInitializationExtensions.cs @@ -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(); diff --git a/Models/MicrosoftGraphConnection.cs b/Models/MicrosoftGraphConnection.cs new file mode 100644 index 0000000..15aa99b --- /dev/null +++ b/Models/MicrosoftGraphConnection.cs @@ -0,0 +1,20 @@ +namespace JobTrackerApi.Models; + +public sealed class MicrosoftGraphConnection +{ + public int Id { get; set; } + public string OwnerUserId { get; set; } = ""; + public string MailAddress { get; set; } = ""; + public string EncryptedRefreshToken { get; set; } = ""; + public string? EncryptedAccessToken { get; set; } + public DateTimeOffset? AccessTokenExpiresAt { get; set; } + public string Scope { get; set; } = ""; + public DateTimeOffset ConnectedAt { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? LastSyncedAt { get; set; } + public DateTimeOffset? LastSyncAttemptedAt { get; set; } + public DateTimeOffset? LastSyncSucceededAt { get; set; } + public string? LastSyncMode { get; set; } + public string? LastSyncSource { get; set; } + public string? LastSyncStatus { get; set; } + public string? LastSyncError { get; set; } +} diff --git a/docker-compose.yml b/docker-compose.yml index a3f8de9..87eef93 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,11 @@ services: - Auth__GoogleClientId=${AUTH_GOOGLE_CLIENT_ID} - Google__GmailClientSecret=${GOOGLE_GMAIL_CLIENT_SECRET} - Google__GmailRedirectUri=${GOOGLE_GMAIL_REDIRECT_URI} + # Optional: Outlook / Microsoft 365 mail linking via Microsoft Graph + - Microsoft__ClientId=${MICROSOFT_CLIENT_ID} + - Microsoft__ClientSecret=${MICROSOFT_CLIENT_SECRET} + - Microsoft__TenantId=${MICROSOFT_TENANT_ID} + - Microsoft__RedirectUri=${MICROSOFT_REDIRECT_URI} - Ai__BaseUrl=${AI_SERVICE_BASE_URL:-http://ai-service:8001} - Summarizer__BaseUrl=${SUMMARIZER_BASE_URL:-http://ai-service:8001} # Email (SMTP)