Compare commits

..

1 Commits

Author SHA1 Message Date
cesnimda 29cfeccc87 chore: stop tracking .gsd planning tooling
CI and Deploy / test (pull_request) Successful in 2m21s
CI and Deploy / deploy (pull_request) Has been skipped
Per project rule that tooling (claude, gsd, etc.) should not live in the
repo. Local files are untouched; .gsd/ is now gitignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 12:50:55 +02:00
16 changed files with 23 additions and 1164 deletions
-6
View File
@@ -8,12 +8,6 @@ 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://<your-domain>/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://<your-domain>/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
+5 -22
View File
@@ -13,22 +13,10 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET (resilient)
shell: bash
# actions/setup-dotnet on this single self-hosted runner intermittently
# leaves a partial extraction in the shared tool-cache ("tar: Cannot open:
# File exists") or corrupts the SDK download. Install into a clean private
# dir via dotnet-install.sh and retry once on failure, mirroring the
# npm ci / NuGet retries elsewhere in this workflow.
run: |
install() {
curl -fsSL https://dot.net/v1/dotnet-install.sh -o /tmp/dotnet-install.sh
rm -rf "$HOME/.dotnet"
bash /tmp/dotnet-install.sh --channel 9.0 --install-dir "$HOME/.dotnet"
}
install || ( echo "dotnet install failed ($?) — retrying once..." && install )
echo "$HOME/.dotnet" >> "$GITHUB_PATH"
"$HOME/.dotnet/dotnet" --info
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- name: Setup Node
uses: actions/setup-node@v4
@@ -70,12 +58,7 @@ jobs:
CI: 'false'
GENERATE_SOURCEMAP: 'false'
NODE_OPTIONS: --max-old-space-size=4096
# CRA's build (Terser minify + fork-ts-checker workers) has repeatedly died silently on
# this runner with no error output (OOM/SIGSEGV signature — same resource-starved-runner
# class as the npm ci and dotnet-install flakes elsewhere in this workflow). Retry once.
run: |
npm run build \
|| ( echo "Frontend build failed ($?) — retrying once..." && npm run build )
run: npm run build
deploy:
needs: test
-1
View File
@@ -18,7 +18,6 @@ namespace JobTrackerApi.Data
public DbSet<Correspondence> Correspondences => Set<Correspondence>();
public DbSet<GmailConnection> GmailConnections => Set<GmailConnection>();
public DbSet<GmailReviewDecision> GmailReviewDecisions => Set<GmailReviewDecision>();
public DbSet<MicrosoftGraphConnection> MicrosoftGraphConnections => Set<MicrosoftGraphConnection>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -1,157 +0,0 @@
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<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<OkObjectResult>(result.Result);
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(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<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync((MicrosoftGraphConnection?)null);
var controller = CreateController(graph.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<MicrosoftGraphController.MicrosoftGraphConnectionStatusDto>(ok.Value);
Assert.False(payload.Connected);
Assert.Null(payload.MailAddress);
}
[Fact]
public void ConnectUrl_returns_authorization_url_from_service()
{
var graph = new Mock<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.BuildAuthorizationUrl("user-1", It.IsAny<string>()))
.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<OkObjectResult>(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<IMicrosoftGraphOAuthService>();
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<ContentResult>(result);
Assert.Contains("no longer valid", content.Content);
graph.Verify(service => service.ExchangeCodeAsync(It.IsAny<string>(), It.IsAny<string>(), It.IsAny<string>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Callback_returns_error_html_when_provider_returns_error()
{
var graph = new Mock<IMicrosoftGraphOAuthService>(MockBehavior.Strict);
var controller = CreateController(graph.Object, "user-1");
var result = await controller.Callback(null, null, "access_denied", CancellationToken.None);
var content = Assert.IsType<ContentResult>(result);
Assert.Contains("access_denied", content.Content);
}
[Fact]
public async Task Callback_exchanges_code_and_reports_connected_mail_address()
{
var graph = new Mock<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.ConsumeState("good-state")).Returns("user-1");
graph.Setup(service => service.ExchangeCodeAsync("user-1", "auth-code", It.IsAny<string>(), It.IsAny<CancellationToken>()))
.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<ContentResult>(result);
Assert.Contains("user@outlook.test", content.Content);
}
[Fact]
public async Task Disconnect_calls_service_for_authenticated_user()
{
var graph = new Mock<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var controller = CreateController(graph.Object, "user-1");
var result = await controller.Disconnect(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
graph.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), 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<string, string?>())
.Build();
}
}
@@ -1,82 +0,0 @@
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<IMicrosoftGraphOAuthService>());
Assert.Equal("microsoft", provider.ProviderKey);
}
[Fact]
public async Task GetConnectionAsync_maps_mail_address_onto_neutral_shape()
{
var graph = new Mock<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.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<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<MicrosoftGraphMessageSummary>
{
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<IMicrosoftGraphOAuthService>();
graph.Setup(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new MicrosoftGraphMessageDetail(
"msg-1", "conv-1", "Offer", "them@company.test", "me@outlook.test", DateTimeOffset.UtcNow, "snippet",
"body text", "<p>body</p>", new List<string> { "Inbox" },
new List<MicrosoftGraphMessageAttachment> { 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);
}
}
+10 -20
View File
@@ -3,7 +3,6 @@ using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
@@ -19,24 +18,15 @@ public sealed class GmailController : ControllerBase
private readonly IGmailJobMatchingService _matching;
private readonly JobTrackerContext _db;
private readonly IConfiguration _cfg;
private readonly IEmailProviderRegistry _providers;
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg)
{
_gmail = gmail;
_matching = matching;
_db = db;
_cfg = cfg;
// Fall back to a single-Gmail registry so direct construction (tests) keeps working.
_providers = providers ?? new EmailProviderRegistry(new IEmailProvider[] { new GmailProvider(gmail) });
}
// The email provider backing this controller's read paths (search + thread listing),
// via the provider-neutral seam. OAuth and Gmail-specific candidate ranking still use
// IGmailOAuthService directly until they are generalised.
private IEmailProvider Email => _providers.Get("gmail")
?? throw new InvalidOperationException("Gmail email provider is not registered.");
public sealed record GmailImportResultDto(int Imported, int Skipped, string? ThreadId);
public sealed record GmailImportMessageResultDto(int Imported, int Skipped, string MessageId, string? ThreadId, Correspondence? Message);
public sealed record ImportGmailMessageRequest(int JobApplicationId, string MessageId);
@@ -393,7 +383,7 @@ public sealed class GmailController : ControllerBase
.FirstOrDefaultAsync(x => x.Id == request.JobApplicationId.Value, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
@@ -649,7 +639,7 @@ public sealed class GmailController : ControllerBase
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
// Batch the "already imported?" check with a single query instead of one
// AnyAsync per message (N+1), mirroring RelinkThread below.
@@ -705,7 +695,7 @@ public sealed class GmailController : ControllerBase
}
}
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctMessageIds = threadMessages.Select(message => message.Id).Where(static id => !string.IsNullOrWhiteSpace(id)).Distinct(StringComparer.Ordinal).ToList();
var existingMessageIds = await _db.Correspondences
.Where(message => message.JobApplicationId == job.Id && message.ExternalMessageId != null && distinctMessageIds.Contains(message.ExternalMessageId))
@@ -803,7 +793,7 @@ public sealed class GmailController : ControllerBase
public async Task<IActionResult> Messages([FromQuery] string? query, [FromQuery] int maxResults = 12, CancellationToken cancellationToken = default)
{
var ownerUserId = GetRequiredOwnerUserId();
var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
var items = await _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
@@ -907,7 +897,7 @@ public sealed class GmailController : ControllerBase
foreach (var threadId in linkedThreadIds)
{
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctThreadMessages = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.GroupBy(message => message.Id, StringComparer.Ordinal)
@@ -951,9 +941,9 @@ public sealed class GmailController : ControllerBase
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
{
var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.Address ?? string.Empty;
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
@@ -974,7 +964,7 @@ public sealed class GmailController : ControllerBase
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.ExternalAttachmentId,
GmailAttachmentId = attachment.GmailAttachmentId,
Inline = attachment.Inline,
})),
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
@@ -1,150 +0,0 @@
using System.Security.Claims;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
/// <summary>
/// Outlook / Microsoft 365 connection lifecycle (connect, OAuth callback, status, disconnect).
/// Mirrors the Gmail OAuth surface in <see cref="GmailController"/>. Message search/import runs
/// through the provider-neutral <c>IEmailProvider</c> seam once a job's correspondence flow is
/// generalised past Gmail; this controller only owns the Microsoft-specific connection lifecycle.
/// </summary>
[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<ActionResult<MicrosoftGraphConnectionStatusDto>> 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<IActionResult> 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<IActionResult> 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 $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Outlook connection</title>
</head>
<body style=""font-family:Segoe UI,Arial,sans-serif;padding:24px;line-height:1.5;"">
<h2>{title}</h2>
<p>{escaped}</p>
<p>You can close this window.</p>
<script>
if (window.opener) {{
window.opener.postMessage({{ source: 'jobtracker-microsoft-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
+1 -3
View File
@@ -165,11 +165,9 @@ builder.Services.AddSingleton<IGoogleTokenValidator, GoogleTokenValidator>();
builder.Services.AddScoped<IGmailOAuthService, GmailOAuthService>();
builder.Services.AddSingleton<IGmailJobMatchingService, GmailJobMatchingService>();
builder.Services.AddSingleton<IGmailCorrespondenceEnrichmentService, NoOpGmailCorrespondenceEnrichmentService>();
builder.Services.AddScoped<IMicrosoftGraphOAuthService, MicrosoftGraphOAuthService>();
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph today; IMAP / manual next).
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.GmailProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProvider, JobTrackerApi.Services.EmailProviders.MicrosoftGraphProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
@@ -1,63 +0,0 @@
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);
}
}
@@ -1,507 +0,0 @@
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,32 +360,6 @@ 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, """
@@ -454,7 +428,6 @@ public static class StartupInitializationExtensions
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureCvTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
@@ -633,7 +606,6 @@ 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");
@@ -827,30 +799,6 @@ 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();
@@ -942,20 +890,6 @@ 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();
-20
View File
@@ -1,20 +0,0 @@
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; }
}
-5
View File
@@ -23,11 +23,6 @@ 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)
+1 -1
View File
@@ -203,7 +203,7 @@ function Shell({ jobPageSize, setJobPageSize, jobColumns, setJobColumns, themeMo
}, []);
if (requireAuth === null || !authResolved) return <Box sx={{ p: 4 }}><Typography variant="h6">Loading...</Typography></Box>;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
if (requireAuth && !me) return <Navigate to="/login" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
@@ -1,49 +0,0 @@
import React from "react";
import "@testing-library/jest-dom";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom";
import { api } from "./api";
import LandingPage from "./pages/LandingPage";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
}));
const mockedApi = api as jest.Mocked<typeof api>;
function LoginStub() {
const location = useLocation() as { state?: { from?: string } };
return <div>login page, from={location.state?.from ?? "none"}</div>;
}
// Regression check for the auth-guard fix: a protected route bounces an
// unauthenticated visitor to "/" with `state.from` set to the page they
// wanted. The home page must forward that state to /login so sign-in
// returns them to the originally-requested page instead of dropping them
// on /jobs.
describe("LandingPage forwards deep-link intent to /login", () => {
it("preserves location.state.from through the Sign in CTA", async () => {
mockedApi.get.mockRejectedValueOnce(new Error("401")); // /auth/me: not signed in
render(
<MemoryRouter initialEntries={[{ pathname: "/", state: { from: "/jobs/42" } }]}>
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/login" element={<LoginStub />} />
</Routes>
</MemoryRouter>,
);
await waitFor(() => expect(mockedApi.get).toHaveBeenCalledWith("/auth/me"));
const signInButtons = await screen.findAllByText("Sign in", { selector: "button" });
await userEvent.click(signInButtons[0]);
expect(await screen.findByText("login page, from=/jobs/42")).toBeInTheDocument();
});
});
+6 -12
View File
@@ -2,7 +2,7 @@ import React, { useEffect, useState } from "react";
import { Box, Button, Container, Stack, Typography } from "@mui/material";
import { alpha } from "@mui/material/styles";
import { useLocation, useNavigate } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
@@ -42,7 +42,6 @@ const PRICING: { name: string; price: string; cadence: string; highlight: boolea
export default function LandingPage() {
const navigate = useNavigate();
const location = useLocation() as { state?: { from?: string } };
const [checking, setChecking] = useState(true);
// If the visitor already has a session, send them straight into the app.
@@ -55,11 +54,6 @@ export default function LandingPage() {
return () => { active = false; };
}, [navigate]);
// A protected route redirects unauthenticated visitors here with the page they
// wanted in location state; forward it to /login so sign-in returns them there
// instead of dropping them on /jobs.
const goToLogin = () => navigate("/login", { state: location.state });
if (checking) {
return (
<Box sx={{ minHeight: "100vh", display: "grid", placeItems: "center", bgcolor: BRAND_DARK }}>
@@ -84,7 +78,7 @@ export default function LandingPage() {
<Box sx={{ width: 30, height: 30, borderRadius: "8px", background: "linear-gradient(135deg,#6366f1,#22d3ee)", display: "grid", placeItems: "center", color: BRAND_DARK, fontWeight: 900 }}></Box>
<Typography sx={{ color: "#fff", fontWeight: 800, fontSize: 20 }}>JobTrack</Typography>
</Stack>
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
<Button variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
@@ -106,7 +100,7 @@ export default function LandingPage() {
right job all in one focused workspace. Assistive, never autonomous: you approve every draft.
</Typography>
<Stack direction={{ xs: "column", sm: "row" }} spacing={2}>
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
Get started
</Button>
<Button size="large" variant="outlined" href="#features" sx={{ color: "#e2e8f0", borderColor: alpha("#ffffff", 0.25), px: 3, py: 1.25 }}>
@@ -223,7 +217,7 @@ export default function LandingPage() {
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={goToLogin}
onClick={() => navigate("/login")}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
@@ -243,7 +237,7 @@ export default function LandingPage() {
<Typography sx={{ fontWeight: 800, fontSize: { xs: 24, md: 30 }, mb: 1 }}>Ready to organize your search?</Typography>
<Typography sx={{ color: "#94a3b8", fontSize: 17 }}>Sign in to start tracking applications, tailoring CVs, and following up with intent.</Typography>
</Box>
<Button size="large" variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
<Button size="large" variant="contained" onClick={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
Sign in
</Button>
</Box>
@@ -254,7 +248,7 @@ export default function LandingPage() {
<Container maxWidth="lg">
<Stack direction={{ xs: "column", sm: "row" }} justifyContent="space-between" alignItems="center" spacing={1}>
<Typography sx={{ color: "text.secondary", fontSize: 14 }}>© {new Date().getFullYear()} JobTrack a focused workspace for the modern job search.</Typography>
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
<Button variant="text" onClick={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>