Compare commits

..

24 Commits

Author SHA1 Message Date
cesnimda ab79072e52 refactor(gmail): extract DTOs and static helpers from GmailController
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 3 (Wave 2), GmailController slice. Pure mechanical extraction,
no behaviour change:

- GmailDtos.cs: the 26 inline record DTOs, moved to a partial-class file so
  every existing GmailController.XyzDto reference (tests included) keeps
  working unchanged.
- GmailParsing.cs: the 8 pure static helpers (ApplySyncBoundary,
  LooksLikeJobRelatedThread, ToConfidence, ExtractFirstEmail/RecruiterName/
  CompanyName/RoleFromSubject, BuildPopupHtml), same partial-class approach.

GmailController.cs: 1200 -> 1022 lines. 169/169 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:45:48 +02:00
cesnimda 6a43227315 perf(gmail): narrow review-decision lookup to the single ThreadId
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 2. CreateSuggestedJob, RelinkThread, and UnlinkThread each
upserted exactly one GmailReviewDecision by ThreadId but loaded every review
decision for the owner (GmailReviewDecisions.Where(OwnerUserId == x).ToList())
just to linear-scan for the one match. Replaced with FirstOrDefaultAsync
filtered on both OwnerUserId and ThreadId, and added a single-row
UpsertReviewDecision overload alongside the existing dictionary-based one
(still used by the review-queue endpoints, which genuinely need every
decision at once to render the queue).

169/169 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:38:26 +02:00
cesnimda 9b21d5c65d Merge pull request 'chore(db): resync stale EF ModelSnapshot + fix fresh-DB schema gap' (#16) from chore/resync-ef-snapshot into main
CI and Deploy / test (push) Successful in 2m6s
CI and Deploy / deploy (push) Successful in 47s
2026-07-11 20:37:01 +02:00
cesnimda a9a0ddecbc chore(db): resync stale EF ModelSnapshot + fix fresh-DB schema gap
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
Backlog item 1. The committed ModelSnapshot was empty/stale (21 lines, no
entities) -- `dotnet ef migrations add` scaffolded the whole database from
scratch against it, including the ASP.NET Identity tables, which have never
been created by a real EF migration in this repo (always provisioned via the
raw-SQL reconciler in StartupInitializationExtensions.cs -- see
EnsureIdentityTables' own comment). Applying that diff for real would throw
"table/column already exists" on every environment.

Fix: added migration 20260711181039_SyncModelSnapshot with an intentionally
empty Up()/Down() (see its doc comment) -- it only records itself in
__EFMigrationsHistory and regenerates the snapshot to match the live model,
so `dotnet ef migrations add` produces a real diff for the next schema
change instead of the whole database again. Verified zero side effects
against a copy of the dev DB (only inserts one history row) and against a
fresh empty DB (full migration + reconciler chain runs clean).

That fresh-DB verification surfaced a real, previously-undiscovered bug:
EnsureColumn/EnsureMySqlColumn calls for JobApplications/Correspondences/
Companies/Attachments ad-hoc columns all no-op on a truly fresh database
(the tables don't exist yet -- Migrate() creates them afterward), so a
brand-new deployment's first boot would be missing dozens of columns
(LastReminderEmailSentAt, RecruiterMessageDraft, salary fields, Correspondence
Provider/Subject/Channel/etc.) until the next restart. Also caught: my own b4
change (Correspondence.Provider backfill, already merged) had the same
unguarded-on-fresh-DB bug in isolation.

Fixed by promoting the schema-reconciliation helpers (Exec/HasTable/
HasColumn/EnsureColumn and their MySQL equivalents) from local functions to
class-level statics, extracting the ad-hoc-column blocks into
ReconcileCoreAppColumns/ReconcileCoreAppColumnsMySql, and calling them a
second time right after Migrate() succeeds (reusing the connection already
opened for the CoreSchemaReady check) -- idempotent, so free on every boot
except the first one, where it's now required. No inline logic changed,
pure extraction + one additional call site.

Also added Microsoft.EntityFrameworkCore.Design to JobTrackerApi.csproj
(dotnet-ef tooling requires it on the startup project since EF Core 6+;
previously only referenced by JobTrackerBackend, where the DbContext lives).

169/169 backend tests green. Verified live: full app boot against both a
fresh empty SQLite DB and a copy of the populated dev DB, both clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 20:32:07 +02:00
cesnimda 408da93fc7 Merge pull request 'feat(email): add provider picker to Settings > Account' (#14) from feat/email-provider-picker-ui into main
CI and Deploy / test (push) Successful in 2m3s
CI and Deploy / deploy (push) Successful in 55s
2026-07-11 19:59:25 +02:00
cesnimda 6db3bffb2f feat(email): add provider picker to Settings > Account
CI and Deploy / test (pull_request) Successful in 2m8s
CI and Deploy / deploy (pull_request) Has been skipped
b5 of the multi-provider email roadmap (frontend). Adds EmailProviderConnections
-- one card per provider (Gmail, Outlook/Microsoft 365, generic IMAP) showing
connect status and connect/disconnect actions, mounted in SettingsView's
Account tab alongside the existing app-login GoogleAuthCard (a separate
concern: that card is sign-in identity, this is mailbox linking).

Gmail and Microsoft reuse the OAuth-popup + postMessage handshake already
built server-side (mirrors Correspondence.tsx's existing Gmail-connect flow).
IMAP has no OAuth step, so it's a plain host/port/ssl/username/password form
posting to /api/imap/connect, which verifies the connection server-side
before storing it.

Deliberately NOT touched: the Gmail-specific job-candidate-matching/review UI
in Correspondence.tsx and GmailReviewPage.tsx. That backend pipeline
(ListJobCandidateMessagesAsync, GmailReviewDecisions) is still Gmail-only by
design -- generalising it now would mean building fake UI for capabilities
Microsoft/IMAP don't have yet. This is scoped to the piece that's actually
provider-neutral: connect/disconnect status.

Verified live (backend + frontend dev servers): logged in, confirmed all
three /status calls return 200, Gmail connect-url fetch succeeds, IMAP form
submit hits /api/imap/connect and surfaces the expected 400 on a bad host.

Frontend suite: 25 suites / 57 tests green (2 new).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:53:51 +02:00
cesnimda c0d620f528 Merge pull request 'feat(email): add Correspondence.Provider discriminator' (#13) from feat/correspondence-provider-discriminator into main
CI and Deploy / test (push) Successful in 2m7s
CI and Deploy / deploy (push) Successful in 43s
2026-07-11 19:53:32 +02:00
cesnimda cb2715c323 feat(email): add Correspondence.Provider discriminator
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b4 of the multi-provider email roadmap. The manual/free-text correspondence
entry path already existed (CorrespondenceController.Create) -- this slice
was narrower than the roadmap wording suggests: tag every Correspondence row
with which provider it came from (gmail | manual today; microsoft | imap
once those providers grow an import-into-Correspondence path of their own),
not build a new endpoint.

- Correspondence.Provider (nullable string), reconciled via the existing
  EnsureColumn pattern (SQLite + MySQL).
- Idempotent backfill: rows with an ExternalThreadId (historically only
  ever written by Gmail import) get 'gmail'; everything else gets 'manual'.
- GmailController.ImportSingleMessageAsync now tags Provider = "gmail".
- CorrespondenceController.Create now tags Provider = "manual".
- Both write sites use a fixed literal, not request input -- no injection
  surface introduced. Backfill SQL is static, no interpolation.

148/148 green (147 existing + 1 new CorrespondenceControllerTests; the
GmailController import test gained a Provider assertion in place).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:47:52 +02:00
cesnimda d308f1d5d4 Merge pull request 'feat(email): add ImapProvider (generic IMAP for unsupported providers)' (#12) from feat/imap-provider into main
CI and Deploy / test (push) Successful in 2m8s
CI and Deploy / deploy (push) Successful in 48s
2026-07-11 19:47:31 +02:00
cesnimda a8e2f4dc4a feat(email): add ImapProvider (generic IMAP for unsupported providers)
CI and Deploy / test (pull_request) Successful in 2m2s
CI and Deploy / deploy (pull_request) Has been skipped
b3 of the multi-provider email roadmap. Adds ImapConnection model + table
(reconciler pattern, SQLite+MySQL), ImapService (MailKit-backed IMAP client),
ImapProvider implementing the existing IEmailProvider contract unchanged,
and ImapController for credential-based connect (no OAuth — user supplies
host/username/password directly, verified by a live connect before storage).

Scope, documented inline with ponytail: comments:
- INBOX only, no multi-folder support.
- Thread grouping approximates the References/In-Reply-To chain root rather
  than the IMAP THREAD extension, which not every server implements.
- External message ids are IMAP UIDs, scoped to the connection's current
  UIDVALIDITY.

Security: ran the security-audit skill against this diff (credential
handling + arbitrary-host connect is exactly the class of change the
standing security gate exists for). Found and fixed a real SSRF: the
connect endpoint let an authenticated user point the server at an
arbitrary host:port with no internal-range check, and connect-vs-auth
failure was distinguishable to the caller -- together a working oracle to
fingerprint internal services (loopback/RFC1918/link-local/cloud metadata)
from the server's network position. Fixed with EnsureHostIsExternalAsync
(DNS-resolve + reject internal ranges, re-checked on every reconnect to
close the DNS-rebinding gap) and a single generic failure message that no
longer distinguishes connect vs auth failure. 7 regression tests added.

Dependency: MailKit 4.17.0 (MIT license) on JobTrackerBackend.csproj --
stdlib has no IMAP client; hand-rolling IMAP4rev1 (TLS, SASL, MIME parsing)
would be a large, security-sensitive protocol implementation nobody asked
for, so this is the correct dependency, not a stdlib substitute.

168/168 green (161 existing + 7 new SSRF regression tests; the earlier
14 IMAP feature tests are included in the 161).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:40:50 +02:00
cesnimda 8edbdceee9 Merge pull request 'ci: retry frontend build once on silent failure' (#15) from ci/retry-frontend-build into main
CI and Deploy / test (push) Successful in 2m2s
CI and Deploy / deploy (push) Successful in 22s
2026-07-11 19:40:24 +02:00
cesnimda 4f98195592 ci: retry frontend build once on silent failure
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
npm run build (Terser minify + fork-ts-checker workers) has now died three
distinct ways on this runner in this session: a printed Terser minify error,
an explicit SIGSEGV, and a fully silent kill with zero output between
'Creating an optimized production build...' and the failure line (OOM/SIGSEGV
signature — process killed before it could flush an error). All three are the
same resource-starved-runner class as the npm ci and dotnet-install flakes
already retried elsewhere in this workflow. Retry once, matching that pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 19:30:15 +02:00
cesnimda b1d5bd516e Merge pull request 'fix(auth): redirect unauthenticated deep links to home, not /login' (#11) from fix/auth-guard-redirect-home into main
CI and Deploy / test (push) Successful in 2m5s
CI and Deploy / deploy (push) Successful in 55s
2026-07-11 18:39:23 +02:00
cesnimda 3eef06e906 Merge pull request 'feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)' (#10) from feat/microsoft-graph-provider into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Successful in 43s
2026-07-11 18:33:17 +02:00
cesnimda daa9694bc7 fix(auth): redirect unauthenticated deep links to home, not /login
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
Shell (the single auth guard wrapping every protected route under /*)
redirected unauthenticated visitors straight to /login instead of the home
page, contrary to the intended behaviour. Root cause was one line in
App.tsx's Shell render gate.

Everything else in the guard was already correct: a single centralized
check (no per-page duplication), a loading gate that blocks render until
/auth/config + /auth/me resolve (no flicker-redirect), and 401-triggered
re-checks via the axios interceptor + auth-changed event for expired
sessions mid-session.

Fix:
- Shell now redirects to "/" (home) instead of "/login", still passing
  state={{ from: path }} so the originally-requested page isn't lost.
- LandingPage forwards that location.state through to /login on every
  "Sign in" CTA (6 call sites collapsed into one goToLogin() helper), so
  the home-page bounce doesn't drop the deep-link intent — sign-in still
  returns the user to the page they wanted instead of dropping them on
  the default /jobs.
- Added LandingPage.authRedirect.test.tsx covering the from-state handoff
  end to end (Landing -> click Sign in -> /login receives from). Full
  suite: 25 suites, 56 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:19:25 +02:00
cesnimda cacad5cc94 feat(email): add MicrosoftGraphProvider (Outlook/365 via Graph OAuth)
CI and Deploy / test (pull_request) Successful in 2m1s
CI and Deploy / deploy (pull_request) Has been skipped
b2 of the multi-provider email roadmap. Mirrors the Gmail provider's shape
end-to-end so the two stay structurally interchangeable:

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 18:08:11 +02:00
cesnimda 7529b99edd Merge pull request 'refactor(gmail): route message import through IEmailProvider' (#9) from feat/gmail-provider-contract into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Successful in 45s
2026-07-11 13:26:21 +02:00
cesnimda 9cb99a7ba7 refactor(gmail): route message import through IEmailProvider
CI and Deploy / test (pull_request) Successful in 2m3s
CI and Deploy / deploy (pull_request) Has been skipped
ImportSingleMessageAsync now fetches the message + connection through the
provider-neutral seam (Email.GetMessageAsync/GetConnectionAsync), mapping the
neutral ExternalAttachmentId onto CorrespondenceAttachmentMetadata. The
controller's import path no longer touches Gmail directly.

OAuth lifecycle, the rich connection-status DTO, and Gmail candidate ranking
stay on IGmailOAuthService until a second provider (Microsoft/IMAP) forces the
contract shape. 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:18:49 +02:00
cesnimda ad1d0d7b2d Merge pull request 'refactor(gmail): route controller read paths through IEmailProvider' (#7) from feat/gmail-provider-reads into main
CI and Deploy / test (push) Successful in 1m59s
CI and Deploy / deploy (push) Successful in 42s
2026-07-11 13:18:34 +02:00
cesnimda 97900dc05b Merge pull request 'chore: stop tracking .gsd planning tooling' (#6) from chore/untrack-gsd into main
CI and Deploy / test (push) Successful in 2m0s
CI and Deploy / deploy (push) Successful in 19s
2026-07-11 13:18:08 +02:00
cesnimda 9febc2b22f refactor(gmail): route controller read paths through IEmailProvider
CI and Deploy / test (pull_request) Successful in 1m58s
CI and Deploy / deploy (pull_request) Has been skipped
GmailController now resolves the "gmail" provider from IEmailProviderRegistry and
uses the provider-neutral seam for its read paths — message search (SearchAsync)
and thread listing (ListThreadMessagesAsync) across ImportThread, RelinkThread,
CreateSuggestedJob, RefreshLinkedThreads and the messages endpoint. OAuth
(connect/callback), connection status and Gmail-specific candidate ranking stay
on IGmailOAuthService until they are generalised.

An optional constructor param keeps direct construction (tests) working via a
fallback single-Gmail registry, so the mocked Gmail service is exercised through
GmailProvider. Behaviour is preserved (neutral DTOs mirror the Gmail shapes).

This makes the seam a real consumer and sets up MicrosoftGraphProvider /
ImapProvider / a manual free-text provider to slot in next.

Build clean; backend suite 135/135 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:11:10 +02:00
cesnimda 6ad3eec7bd chore: stop tracking .gsd planning tooling
CI and Deploy / test (pull_request) Successful in 2m0s
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 13:11:10 +02:00
cesnimda 35eaef9dee Merge pull request 'ci: resilient .NET setup (retry dotnet-install, drop flaky setup-dotnet)' (#8) from ci/resilient-dotnet-setup into main
CI and Deploy / test (push) Successful in 1m59s
CI and Deploy / deploy (push) Successful in 21s
2026-07-11 13:10:57 +02:00
cesnimda fc356012e6 ci: install .NET via retrying dotnet-install.sh instead of setup-dotnet
CI and Deploy / test (pull_request) Successful in 2m5s
CI and Deploy / deploy (pull_request) Has been skipped
The single self-hosted act_runner intermittently fails actions/setup-dotnet:
a partial extraction sticks in the shared tool-cache (tar: Cannot open: File
exists) or the SDK tarball download corrupts. Install into a clean private
$HOME/.dotnet via dotnet-install.sh with a rm -rf + retry-once, matching the
npm ci and NuGet publish retries already in this pipeline.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 13:02:37 +02:00
38 changed files with 5168 additions and 361 deletions
+6
View File
@@ -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://<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
+22 -5
View File
@@ -13,10 +13,22 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '9.0.x'
- 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 Node
uses: actions/setup-node@v4
@@ -58,7 +70,12 @@ jobs:
CI: 'false'
GENERATE_SOURCEMAP: 'false'
NODE_OPTIONS: --max-old-space-size=4096
run: npm run build
# 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 )
deploy:
needs: test
+2
View File
@@ -18,6 +18,8 @@ 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<ImapConnection> ImapConnections => Set<ImapConnection>();
public DbSet<Attachment> Attachments => Set<Attachment>();
public DbSet<RuleSettings> RuleSettings => Set<RuleSettings>();
public DbSet<UserRuleSettings> UserRuleSettings => Set<UserRuleSettings>();
@@ -0,0 +1,35 @@
using JobTrackerApi.Controllers;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class CorrespondenceControllerTests
{
[Fact]
public async Task Create_tags_manually_entered_correspondence_with_manual_provider()
{
await using var db = TestHostFactory.CreateInMemoryDb();
var company = new Company { Name = "Acme", OwnerUserId = "user-1" };
db.Companies.Add(company);
await db.SaveChangesAsync();
var job = new JobApplication { JobTitle = "Backend Developer", CompanyId = company.Id, OwnerUserId = "user-1" };
db.JobApplications.Add(job);
await db.SaveChangesAsync();
var controller = new CorrespondenceController(db);
var request = new CorrespondenceController.CreateCorrespondenceRequestV2(
job.Id, "Me", "Called to follow up.", "Follow-up call", "Call", null, "outbound", null, null, null, null, null, null);
var result = await controller.Create(request, CancellationToken.None);
Assert.IsType<Correspondence>(((CreatedAtActionResult)result.Result!).Value);
var stored = await db.Correspondences.SingleAsync();
Assert.Equal("manual", stored.Provider);
}
}
@@ -288,6 +288,7 @@ public sealed class GmailControllerTests
var storedMessages = await db.Correspondences.Where(message => message.JobApplicationId == job.Id).ToListAsync();
Assert.Single(storedMessages);
Assert.Equal("gmail", storedMessages[0].Provider);
gmail.Verify(service => service.GetMessageAsync("user-1", "msg-1", It.IsAny<CancellationToken>()), Times.Once);
}
+131
View File
@@ -0,0 +1,131 @@
using System.Security.Claims;
using JobTrackerApi.Controllers;
using JobTrackerApi.Models;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class ImapControllerTests
{
[Fact]
public async Task Status_returns_connection_fields_for_connected_account()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImapConnection
{
OwnerUserId = "user-1",
Host = "imap.example.test",
Port = 993,
UseSsl = true,
Username = "user@example.test",
ConnectedAt = DateTimeOffset.UtcNow.AddDays(-1),
LastSyncStatus = "ok"
});
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
Assert.True(payload.Connected);
Assert.Equal("imap.example.test", payload.Host);
Assert.Equal(993, payload.Port);
Assert.Equal("user@example.test", payload.Username);
Assert.Equal("ok", payload.LastSyncStatus);
}
[Fact]
public async Task Status_reports_not_connected_when_no_connection_exists()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync((ImapConnection?)null);
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Status(CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<ImapController.ImapConnectionStatusDto>(ok.Value);
Assert.False(payload.Connected);
}
[Theory]
[InlineData("", 993, "user", "pass", "Host is required.")]
[InlineData("imap.example.test", 0, "user", "pass", "Valid port is required.")]
[InlineData("imap.example.test", 993, "", "pass", "Username is required.")]
[InlineData("imap.example.test", 993, "user", "", "Password is required.")]
public async Task Connect_rejects_missing_fields(string host, int port, string username, string password, string expectedError)
{
var imap = new Mock<IImapService>(MockBehavior.Strict);
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Connect(new ImapController.ImapConnectRequest(host, port, true, username, password), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Equal(expectedError, badRequest.Value);
}
[Fact]
public async Task Connect_returns_bad_request_when_service_rejects_credentials()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user", "wrong", It.IsAny<CancellationToken>()))
.ThrowsAsync(new InvalidOperationException("IMAP authentication failed: bad credentials"));
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user", "wrong"), CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
Assert.Contains("authentication failed", (string)badRequest.Value!);
}
[Fact]
public async Task Connect_succeeds_and_returns_username()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.ConnectAsync("user-1", "imap.example.test", 993, true, "user@example.test", "correct", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImapConnectResult("user@example.test"));
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Connect(new ImapController.ImapConnectRequest("imap.example.test", 993, true, "user@example.test", "correct"), CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result);
var username = ok.Value!.GetType().GetProperty("username")!.GetValue(ok.Value) as string;
Assert.Equal("user@example.test", username);
}
[Fact]
public async Task Disconnect_calls_service_for_authenticated_user()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>())).Returns(Task.CompletedTask);
var controller = CreateController(imap.Object, "user-1");
var result = await controller.Disconnect(CancellationToken.None);
Assert.IsType<NoContentResult>(result);
imap.Verify(service => service.DisconnectAsync("user-1", It.IsAny<CancellationToken>()), Times.Once);
}
private static ImapController CreateController(IImapService imap, string userId)
{
return new ImapController(imap)
{
ControllerContext = new ControllerContext
{
HttpContext = new DefaultHttpContext
{
User = new ClaimsPrincipal(new ClaimsIdentity(new[]
{
new Claim(ClaimTypes.NameIdentifier, userId)
}, "test"))
}
}
};
}
}
+82
View File
@@ -0,0 +1,82 @@
using JobTrackerApi.Services;
using JobTrackerApi.Services.EmailProviders;
using Moq;
using Xunit;
namespace JobTrackerApi.Tests;
public sealed class ImapProviderTests
{
[Fact]
public void ProviderKey_is_imap()
{
var provider = new ImapProvider(Mock.Of<IImapService>());
Assert.Equal("imap", provider.ProviderKey);
}
[Fact]
public async Task GetConnectionAsync_maps_username_onto_neutral_shape()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync(new JobTrackerApi.Models.ImapConnection { OwnerUserId = "user-1", Username = "user@example.test" });
var provider = new ImapProvider(imap.Object);
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
Assert.NotNull(connection);
Assert.Equal("imap", connection!.ProviderKey);
Assert.Equal("user@example.test", connection.Address);
}
[Fact]
public async Task GetConnectionAsync_returns_null_when_not_connected()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetConnectionAsync("user-1", It.IsAny<CancellationToken>()))
.ReturnsAsync((JobTrackerApi.Models.ImapConnection?)null);
var provider = new ImapProvider(imap.Object);
var connection = await provider.GetConnectionAsync("user-1", CancellationToken.None);
Assert.Null(connection);
}
[Fact]
public async Task SearchAsync_maps_thread_key_onto_neutral_thread_id()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.ListMessagesAsync("user-1", "recruiter", 10, It.IsAny<CancellationToken>()))
.ReturnsAsync(new List<ImapMessageSummary>
{
new("42", "root-msg-id@example.test", "Interview", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet")
});
var provider = new ImapProvider(imap.Object);
var results = await provider.SearchAsync("user-1", "recruiter", 10, CancellationToken.None);
var summary = Assert.Single(results);
Assert.Equal("42", summary.Id);
Assert.Equal("root-msg-id@example.test", summary.ThreadId);
Assert.Equal("Interview", summary.Subject);
}
[Fact]
public async Task GetMessageAsync_maps_content_id_onto_neutral_external_attachment_id()
{
var imap = new Mock<IImapService>();
imap.Setup(service => service.GetMessageAsync("user-1", "42", It.IsAny<CancellationToken>()))
.ReturnsAsync(new ImapMessageDetail(
"42", "root-msg-id@example.test", "Offer", "them@company.test", "me@example.test", DateTimeOffset.UtcNow, "snippet",
"body text", "<p>body</p>", new List<string>(),
new List<ImapMessageAttachment> { new("resume.pdf", "application/pdf", 1024, "cid-1", false) }));
var provider = new ImapProvider(imap.Object);
var detail = await provider.GetMessageAsync("user-1", "42", CancellationToken.None);
Assert.Equal("root-msg-id@example.test", detail.ThreadId);
var attachment = Assert.Single(detail.Attachments);
Assert.Equal("resume.pdf", attachment.FileName);
Assert.Equal("cid-1", attachment.ExternalAttachmentId);
}
}
@@ -0,0 +1,49 @@
using System.IO;
using JobTrackerApi.Services;
using JobTrackerApi.Tests.TestSupport;
using Microsoft.AspNetCore.DataProtection;
using Xunit;
namespace JobTrackerApi.Tests;
// Regression coverage for the SSRF guard in ImapService: an authenticated user's IMAP "connect"
// target must not be usable to probe loopback/RFC1918/link-local/cloud-metadata addresses.
public sealed class ImapServiceSsrfGuardTests
{
[Theory]
[InlineData("127.0.0.1")]
[InlineData("localhost")]
[InlineData("10.0.0.5")]
[InlineData("172.16.0.5")]
[InlineData("192.168.1.5")]
[InlineData("169.254.169.254")] // cloud metadata endpoint
public async Task ConnectAsync_rejects_internal_and_metadata_hosts(string host)
{
var service = CreateService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.ConnectAsync("user-1", host, 993, true, "user", "password", CancellationToken.None));
// Message must not leak connect-vs-auth distinction (that's the oracle this guard closes).
Assert.DoesNotContain("resolve", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("reachable", ex.Message, StringComparison.OrdinalIgnoreCase);
}
[Fact]
public async Task ConnectAsync_rejects_unresolvable_host_without_leaking_dns_detail()
{
var service = CreateService();
var ex = await Assert.ThrowsAsync<InvalidOperationException>(() =>
service.ConnectAsync("user-1", "this-host-does-not-exist.invalid", 993, true, "user", "password", CancellationToken.None));
Assert.Equal("Could not connect to that IMAP server with the given credentials. Check host, port, and password.", ex.Message);
}
private static ImapService CreateService()
{
var db = TestHostFactory.CreateInMemoryDb();
var protectionProvider = DataProtectionProvider.Create(new DirectoryInfo(Path.Combine(Path.GetTempPath(), $"jobtracker-tests-{Guid.NewGuid():N}")));
return new ImapService(db, protectionProvider);
}
}
@@ -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<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();
}
}
@@ -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<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);
}
}
@@ -159,6 +159,7 @@ namespace JobTrackerApi.Controllers
ExternalTo = string.IsNullOrWhiteSpace(request.ExternalTo) ? null : request.ExternalTo.Trim(),
ExternalLabelsJson = string.IsNullOrWhiteSpace(request.ExternalLabelsJson) ? null : request.ExternalLabelsJson.Trim(),
AttachmentMetadataJson = string.IsNullOrWhiteSpace(request.AttachmentMetadataJson) ? null : request.AttachmentMetadataJson.Trim(),
Provider = "manual",
Content = request.Content,
Date = request.Date ?? DateTime.Now,
};
+34 -196
View File
@@ -3,6 +3,7 @@ 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;
@@ -12,91 +13,29 @@ namespace JobTrackerApi.Controllers;
[ApiController]
[Route("api/gmail")]
[Authorize]
public sealed class GmailController : ControllerBase
public sealed partial class GmailController : ControllerBase
{
private readonly IGmailOAuthService _gmail;
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)
public GmailController(IGmailOAuthService gmail, IGmailJobMatchingService matching, JobTrackerContext db, IConfiguration cfg, IEmailProviderRegistry? providers = null)
{
_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) });
}
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);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
// 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.");
[HttpGet("status")]
public async Task<ActionResult<GmailConnectionStatusDto>> Status(CancellationToken cancellationToken)
@@ -383,7 +322,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 _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var distinctMessageIds = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.Select(message => message.Id)
@@ -639,7 +578,7 @@ public sealed class GmailController : ControllerBase
_db.JobApplications.Add(job);
await _db.SaveChangesAsync(cancellationToken);
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, request.ThreadId.Trim(), cancellationToken);
var threadMessages = await Email.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.
@@ -661,7 +600,9 @@ public sealed class GmailController : ControllerBase
imported++;
}
UpsertReviewDecision(await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken), ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
var suggestedJobReviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == request.ThreadId.Trim(), cancellationToken);
UpsertReviewDecision(suggestedJobReviewDecision, ownerUserId, request.ThreadId.Trim(), "linked", job.Id, request.Notes);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new CreatedSuggestedGmailJobDto(job.Id, company.Id, request.ThreadId.Trim(), imported, skipped));
}
@@ -695,7 +636,7 @@ public sealed class GmailController : ControllerBase
}
}
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await Email.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))
@@ -715,8 +656,9 @@ public sealed class GmailController : ControllerBase
imported++;
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, "linked", job.Id, request.Note);
var reviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, "linked", job.Id, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailRelinkResultDto(threadId, job.Id, imported, skipped, unlinkedMessages));
}
@@ -742,10 +684,11 @@ public sealed class GmailController : ControllerBase
_db.Correspondences.RemoveRange(messages);
}
var reviewDecisions = await _db.GmailReviewDecisions.Where(x => x.OwnerUserId == ownerUserId).ToListAsync(cancellationToken);
var reviewDecision = await _db.GmailReviewDecisions
.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId && x.ThreadId == threadId, cancellationToken);
var nextDecision = (request.NextDecision ?? "review").Trim().ToLowerInvariant();
if (nextDecision is not ("review" or "suggested" or "rejected")) nextDecision = "review";
UpsertReviewDecision(reviewDecisions, ownerUserId, threadId, nextDecision, null, request.Note);
UpsertReviewDecision(reviewDecision, ownerUserId, threadId, nextDecision, null, request.Note);
await _db.SaveChangesAsync(cancellationToken);
return Ok(new GmailUnlinkResultDto(threadId, job.Id, messages.Count, nextDecision));
}
@@ -793,7 +736,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 _gmail.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
var items = await Email.SearchAsync(ownerUserId, query, maxResults, cancellationToken);
return Ok(items);
}
@@ -897,7 +840,7 @@ public sealed class GmailController : ControllerBase
foreach (var threadId in linkedThreadIds)
{
var threadMessages = await _gmail.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var threadMessages = await Email.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
var distinctThreadMessages = threadMessages
.Where(message => !string.IsNullOrWhiteSpace(message.Id))
.GroupBy(message => message.Id, StringComparer.Ordinal)
@@ -941,9 +884,9 @@ public sealed class GmailController : ControllerBase
private async Task<Correspondence> ImportSingleMessageAsync(string ownerUserId, JobApplication job, string messageId, CancellationToken cancellationToken)
{
var detail = await _gmail.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await _gmail.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.GmailAddress ?? string.Empty;
var detail = await Email.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var me = await Email.GetConnectionAsync(ownerUserId, cancellationToken);
var gmailAddress = me?.Address ?? string.Empty;
var isMe = detail.From.Contains(gmailAddress, StringComparison.OrdinalIgnoreCase);
var messageDate = detail.Date?.LocalDateTime ?? DateTime.Now;
@@ -964,9 +907,10 @@ public sealed class GmailController : ControllerBase
FileName = attachment.FileName,
MimeType = attachment.MimeType,
SizeBytes = attachment.SizeBytes,
GmailAttachmentId = attachment.GmailAttachmentId,
GmailAttachmentId = attachment.ExternalAttachmentId,
Inline = attachment.Inline,
})),
Provider = "gmail",
Content = string.IsNullOrWhiteSpace(detail.BodyText) ? detail.Snippet : detail.BodyText,
Date = messageDate,
};
@@ -1001,40 +945,6 @@ public sealed class GmailController : ControllerBase
return _matching.BuildJobQueries(job, queryOverride);
}
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private void UpsertReviewDecision(IDictionary<string, GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
if (!decisions.TryGetValue(threadId, out var existing))
@@ -1054,9 +964,11 @@ public sealed class GmailController : ControllerBase
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private void UpsertReviewDecision(List<GmailReviewDecision> decisions, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
// Single-thread upsert: callers acting on exactly one ThreadId should load just that row
// (see the FirstOrDefaultAsync call sites below) rather than every review decision for the
// owner just to scan for one match.
private void UpsertReviewDecision(GmailReviewDecision? existing, string ownerUserId, string threadId, string decision, int? jobApplicationId, string? note)
{
var existing = decisions.FirstOrDefault(x => x.ThreadId == threadId);
if (existing is null)
{
existing = new GmailReviewDecision
@@ -1064,7 +976,6 @@ public sealed class GmailController : ControllerBase
OwnerUserId = ownerUserId,
ThreadId = threadId,
};
decisions.Add(existing);
_db.GmailReviewDecisions.Add(existing);
}
@@ -1074,54 +985,6 @@ public sealed class GmailController : ControllerBase
existing.UpdatedAt = DateTimeOffset.UtcNow;
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
@@ -1156,29 +1019,4 @@ public sealed class GmailController : ControllerBase
return $"{Request.Scheme}://{Request.Host}/api/gmail/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 ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail 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-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
+79
View File
@@ -0,0 +1,79 @@
using JobTrackerApi.Models;
namespace JobTrackerApi.Controllers;
// DTOs for GmailController, split out for readability (Wave 2 safe refactor -- no behaviour
// change; these were previously nested inline in the controller file).
public partial class GmailController
{
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);
public sealed record ImportGmailThreadRequest(int JobApplicationId, string ThreadId, string[] MessageIds);
public sealed record RefreshLinkedThreadsRequest(int JobApplicationId);
public sealed record GmailThreadRefreshThreadDto(string ThreadId, int Imported, int Skipped, int TotalMessages, string Status, DateTimeOffset? LatestMessageDate);
public sealed record GmailThreadRefreshResultDto(int JobApplicationId, int ThreadsChecked, int Imported, int Skipped, bool HasLinkedThreads, DateTimeOffset RefreshedAt, IReadOnlyList<GmailThreadRefreshThreadDto> Threads);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
string Subject,
string From,
string To,
DateTimeOffset? Date,
string Snippet,
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
string Subject,
int Score,
string Confidence,
bool HasImportedMessages,
int ImportedMessageCount,
int MessageCount,
DateTimeOffset? LatestDate,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons,
IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailJobMatchesResponseDto(
int JobApplicationId,
string JobTitle,
string CompanyName,
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
public sealed record GmailReviewJobCandidateDto(int JobApplicationId, string JobTitle, string CompanyName, int Score, string Confidence, IReadOnlyList<GmailJobMatchReasonDto> Reasons);
public sealed record GmailReviewThreadDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, int MessageCount, string Routing, bool HasImportedMessages, string? DecisionNote, IReadOnlyList<string> MatchedQueries, IReadOnlyList<GmailReviewJobCandidateDto> JobCandidates, IReadOnlyList<GmailJobMatchedMessageDto> Messages);
public sealed record GmailReviewQueueResponseDto(IReadOnlyList<string> Queries, int CandidateThreadCount, int AutoLinkThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, IReadOnlyList<GmailReviewThreadDto> Threads);
public sealed record SaveGmailReviewDecisionRequest(string ThreadId, string Decision, int? JobApplicationId, string? Note);
public sealed record GmailManualSyncRequest(int? LookbackDays, int? MaxResultsPerQuery, bool? AutoImportHighConfidence, bool? IncludeSpamTrash);
public sealed record GmailManualSyncResultDto(int QueriesRun, int CandidateThreadCount, int AutoLinkedThreadCount, int ReviewThreadCount, int UnmatchedThreadCount, int ImportedMessages, int ImportedThreads, int SkippedMessages, int LookbackDays, bool IncludeSpamTrash, DateTimeOffset SyncedAt);
public sealed record GmailSuggestedJobCandidateDto(string ThreadId, string Subject, DateTimeOffset? LatestDate, string? CompanyName, string? RecruiterName, string? RecruiterEmail, string? SuggestedJobTitle, string Routing, IReadOnlyList<string> MatchedQueries, string Preview);
public sealed record GmailSuggestedJobsResponseDto(int Count, IReadOnlyList<GmailSuggestedJobCandidateDto> Items);
public sealed record CreateSuggestedGmailJobRequest(string ThreadId, string CompanyName, string JobTitle, string? RecruiterName, string? RecruiterEmail, string? Notes, string? Status);
public sealed record CreatedSuggestedGmailJobDto(int JobApplicationId, int CompanyId, string ThreadId, int Imported, int Skipped);
public sealed record RelinkGmailThreadRequest(int JobApplicationId, string ThreadId, bool RemoveFromOtherJobs, string? Note);
public sealed record GmailRelinkResultDto(string ThreadId, int JobApplicationId, int Imported, int Skipped, int UnlinkedMessages);
public sealed record UnlinkGmailThreadRequest(int JobApplicationId, string ThreadId, string? Note, string? NextDecision);
public sealed record GmailUnlinkResultDto(string ThreadId, int JobApplicationId, int RemovedMessages, string Decision);
public sealed record GmailConnectionStatusDto(
bool Connected,
string? GmailAddress,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
}
+116
View File
@@ -0,0 +1,116 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Controllers;
// Pure parsing/formatting helpers for GmailController, split out for readability (Wave 2 safe
// refactor -- no behaviour change). All are static and side-effect free.
public sealed partial class GmailController
{
private static string ApplySyncBoundary(string query, int lookbackDays, bool includeSpamTrash)
{
var bounded = (query ?? string.Empty).Trim();
if (!bounded.Contains("newer_than:", StringComparison.OrdinalIgnoreCase))
{
bounded = string.IsNullOrWhiteSpace(bounded)
? $"newer_than:{lookbackDays}d"
: $"{bounded} newer_than:{lookbackDays}d";
}
if (!includeSpamTrash)
{
if (!bounded.Contains("in:spam", StringComparison.OrdinalIgnoreCase)) bounded += " -in:spam";
if (!bounded.Contains("in:trash", StringComparison.OrdinalIgnoreCase)) bounded += " -in:trash";
}
return bounded.Trim();
}
private static bool LooksLikeJobRelatedThread(IReadOnlyList<GmailQueryMatchedMessage> orderedMessages)
{
var sample = string.Join("\n", orderedMessages.Select(item => string.Join(" ", new[] { item.Message.Subject, item.Message.From, item.Message.Snippet }.Where(value => !string.IsNullOrWhiteSpace(value)))));
if (string.IsNullOrWhiteSpace(sample)) return false;
return sample.Contains("interview", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("application", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("recruit", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("role", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("position", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("offer", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("follow-up", StringComparison.OrdinalIgnoreCase)
|| sample.Contains("rejection", StringComparison.OrdinalIgnoreCase);
}
private static string ToConfidence(int score)
{
return score switch
{
>= 30 => "high",
>= 16 => "medium",
_ => "low"
};
}
private static string? ExtractFirstEmail(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var match = System.Text.RegularExpressions.Regex.Match(value, @"[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}", System.Text.RegularExpressions.RegexOptions.IgnoreCase);
return match.Success ? match.Value : null;
}
private static string? ExtractRecruiterName(string? value)
{
if (string.IsNullOrWhiteSpace(value)) return null;
var trimmed = value.Split('<')[0].Trim().Trim('"');
return string.IsNullOrWhiteSpace(trimmed) || trimmed.Contains('@') ? null : trimmed;
}
private static string? ExtractCompanyName(string? from, string? subject)
{
var subjectText = (subject ?? string.Empty).Trim();
if (!string.IsNullOrWhiteSpace(subjectText))
{
var parts = subjectText.Split(new[] { '-', '', '|' }, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
if (parts.Length >= 2) return parts[0];
}
var recruiterName = ExtractRecruiterName(from);
return recruiterName is { Length: > 0 } && recruiterName.Contains(' ') ? recruiterName.Split(' ').Last() : null;
}
private static string? ExtractRoleFromSubject(string? subject)
{
if (string.IsNullOrWhiteSpace(subject)) return null;
var trimmed = subject.Trim();
if (trimmed.Contains("interview", StringComparison.OrdinalIgnoreCase))
{
return trimmed.Replace("interview", string.Empty, StringComparison.OrdinalIgnoreCase).Trim(' ', '-', ':');
}
return trimmed.Length <= 120 ? trimmed : trimmed[..120];
}
private static string BuildPopupHtml(bool success, string message)
{
var escaped = System.Net.WebUtility.HtmlEncode(message);
var status = success ? "connected" : "error";
var title = success ? "Gmail connected" : "Gmail connection failed";
var serializedMessage = System.Text.Json.JsonSerializer.Serialize(message);
return $@"<!doctype html>
<html>
<head>
<meta charset=""utf-8"" />
<title>Gmail 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-gmail-oauth', status: '{status}', message: {serializedMessage} }}, '*');
}}
window.close();
</script>
</body>
</html>";
}
}
@@ -0,0 +1,96 @@
using System.Security.Claims;
using JobTrackerApi.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
namespace JobTrackerApi.Controllers;
/// <summary>
/// Generic IMAP connection lifecycle for mailboxes with no dedicated OAuth provider. Unlike
/// Gmail/Microsoft, there's no OAuth redirect — the caller submits host/username/password once,
/// <see cref="IImapService"/> verifies them by connecting, then encrypts and stores them.
/// </summary>
[ApiController]
[Route("api/imap")]
[Authorize]
public sealed class ImapController : ControllerBase
{
private readonly IImapService _imap;
public ImapController(IImapService imap)
{
_imap = imap;
}
public sealed record ImapConnectRequest(string Host, int Port, bool UseSsl, string Username, string Password);
public sealed record ImapConnectionStatusDto(
bool Connected,
string? Host,
int? Port,
bool? UseSsl,
string? Username,
DateTimeOffset? ConnectedAt,
DateTimeOffset? LastSyncedAt,
DateTimeOffset? LastSyncAttemptedAt,
DateTimeOffset? LastSyncSucceededAt,
string? LastSyncMode,
string? LastSyncSource,
string? LastSyncStatus,
string? LastSyncError);
[HttpGet("status")]
public async Task<ActionResult<ImapConnectionStatusDto>> Status(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
return Ok(new ImapConnectionStatusDto(
connection is not null,
connection?.Host,
connection?.Port,
connection?.UseSsl,
connection?.Username,
connection?.ConnectedAt,
connection?.LastSyncedAt,
connection?.LastSyncAttemptedAt,
connection?.LastSyncSucceededAt,
connection?.LastSyncMode,
connection?.LastSyncSource,
connection?.LastSyncStatus,
connection?.LastSyncError));
}
[HttpPost("connect")]
public async Task<IActionResult> Connect([FromBody] ImapConnectRequest request, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(request.Host)) return BadRequest("Host is required.");
if (request.Port <= 0 || request.Port > 65535) return BadRequest("Valid port is required.");
if (string.IsNullOrWhiteSpace(request.Username)) return BadRequest("Username is required.");
if (string.IsNullOrWhiteSpace(request.Password)) return BadRequest("Password is required.");
var ownerUserId = GetRequiredOwnerUserId();
try
{
var result = await _imap.ConnectAsync(ownerUserId, request.Host, request.Port, request.UseSsl, request.Username, request.Password, cancellationToken);
return Ok(new { username = result.Username });
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
[HttpDelete("connection")]
public async Task<IActionResult> Disconnect(CancellationToken cancellationToken)
{
var ownerUserId = GetRequiredOwnerUserId();
await _imap.DisconnectAsync(ownerUserId, cancellationToken);
return NoContent();
}
private string GetRequiredOwnerUserId()
{
return User.FindFirstValue(ClaimTypes.NameIdentifier) ?? User.FindFirstValue("sub")
?? throw new InvalidOperationException("Authenticated user id is missing.");
}
}
@@ -0,0 +1,150 @@
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>";
}
}
+6
View File
@@ -13,6 +13,12 @@
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.14" />
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.14" />
<PackageReference Include="Pomelo.EntityFrameworkCore.MySql" Version="9.0.0" />
<!-- dotnet-ef design-time tooling requires this on the startup project (not just
JobTrackerBackend, where the DbContext actually lives) since EF Core 6+. -->
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.14">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace JobTrackerApi.Migrations
{
/// <summary>
/// Intentionally a no-op. The committed ModelSnapshot had drifted far behind the live schema
/// (empty -- see JobTrackerContextModelSnapshot.cs history): every table/column added since
/// the last real migration (2026-03-11) was provisioned exclusively through the idempotent
/// raw-SQL reconciler in StartupInitializationExtensions.cs, including the ASP.NET Identity
/// tables themselves, which have never been created by an EF migration in this repo -- see
/// EnsureIdentityTables' comment ("create Identity tables directly if dotnet ef isn't
/// available"). `dotnet ef migrations add` scaffolded the honest diff against that stale
/// snapshot: full CreateTable/AddColumn operations for schema that already exists on every
/// environment (fresh or established) via that reconciler. Applying that diff for real would
/// throw "table/column already exists" everywhere. This migration exists only to record itself
/// in __EFMigrationsHistory and regenerate JobTrackerContextModelSnapshot.cs to match the
/// current C# model, so `dotnet ef migrations add` produces a real (small) diff for the *next*
/// schema change instead of scaffolding the whole database again. It changes no data or schema.
/// </summary>
public partial class SyncModelSnapshot : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
}
protected override void Down(MigrationBuilder migrationBuilder)
{
}
}
}
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -165,9 +165,13 @@ 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>();
builder.Services.AddScoped<IImapService, ImapService>();
// Provider-neutral email seam (multi-provider: Gmail today; Microsoft Graph / IMAP / manual next).
// Provider-neutral email seam (multi-provider: Gmail + Microsoft Graph + IMAP today; 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.IEmailProvider, JobTrackerApi.Services.EmailProviders.ImapProvider>();
builder.Services.AddScoped<JobTrackerApi.Services.EmailProviders.IEmailProviderRegistry, JobTrackerApi.Services.EmailProviders.EmailProviderRegistry>();
builder.Services.AddIdentityCore<ApplicationUser>(options =>
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Generic IMAP implementation of <see cref="IEmailProvider"/> for mailboxes with no
/// dedicated OAuth provider. Adapts <see cref="IImapService"/> to the provider-neutral
/// contract, mapping IMAP DTOs to the neutral shapes.
/// </summary>
public sealed class ImapProvider : IEmailProvider
{
private readonly IImapService _imap;
public ImapProvider(IImapService imap)
{
_imap = imap;
}
public string ProviderKey => "imap";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _imap.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("imap", connection.Username ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _imap.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 _imap.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _imap.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.ContentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ThreadKey,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(ImapMessageSummary m)
=> new(m.Id, m.ThreadKey, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
@@ -0,0 +1,63 @@
using JobTrackerApi.Services;
namespace JobTrackerApi.Services.EmailProviders
{
/// <summary>
/// Outlook / Microsoft 365 implementation of <see cref="IEmailProvider"/>. Adapts
/// <see cref="IMicrosoftGraphOAuthService"/> (Microsoft Graph client) to the provider-neutral
/// contract, mapping Graph DTOs to the neutral shapes.
/// </summary>
public sealed class MicrosoftGraphProvider : IEmailProvider
{
private readonly IMicrosoftGraphOAuthService _graph;
public MicrosoftGraphProvider(IMicrosoftGraphOAuthService graph)
{
_graph = graph;
}
public string ProviderKey => "microsoft";
public async Task<EmailConnectionInfo?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _graph.GetConnectionAsync(ownerUserId, cancellationToken);
return connection is null ? null : new EmailConnectionInfo("microsoft", connection.MailAddress ?? "");
}
public async Task<IReadOnlyList<EmailMessageSummary>> SearchAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
var messages = await _graph.ListMessagesAsync(ownerUserId, query, maxResults, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<IReadOnlyList<EmailMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadId, CancellationToken cancellationToken)
{
var messages = await _graph.ListThreadMessagesAsync(ownerUserId, threadId, cancellationToken);
return messages.Select(ToSummary).ToList();
}
public async Task<EmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
var detail = await _graph.GetMessageAsync(ownerUserId, messageId, cancellationToken);
var attachments = detail.Attachments
.Select(a => new EmailAttachmentRef(a.FileName, a.MimeType, a.SizeBytes, a.GraphAttachmentId, a.Inline))
.ToList();
return new EmailMessageDetail(
detail.Id,
detail.ConversationId,
detail.Subject,
detail.From,
detail.To,
detail.Date,
detail.Snippet,
detail.BodyText,
detail.BodyHtml,
detail.Labels,
attachments);
}
private static EmailMessageSummary ToSummary(MicrosoftGraphMessageSummary m)
=> new(m.Id, m.ConversationId, m.Subject, m.From, m.To, m.Date, m.Snippet);
}
}
+345
View File
@@ -0,0 +1,345 @@
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using MailKit;
using MailKit.Net.Imap;
using MailKit.Search;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using MimeKit;
using System.Net;
using System.Net.Sockets;
using System.Security.Cryptography;
namespace JobTrackerApi.Services;
public interface IImapService
{
Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken);
Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken);
Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record ImapConnectResult(string Username);
public sealed record ImapMessageSummary(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record ImapMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? ContentId, bool Inline);
public sealed record ImapMessageDetail(string Id, string ThreadKey, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<ImapMessageAttachment> Attachments);
/// <summary>
/// Generic IMAP mail access for "any provider not explicitly supported" (Gmail/Microsoft have
/// their own OAuth-based providers). Auth is direct host/username/password rather than OAuth —
/// there's no connect-url/callback dance, the caller submits credentials once and they're
/// encrypted at rest the same way Gmail/Microsoft's refresh tokens are.
///
/// ponytail: scoped to INBOX only, and "thread" is approximated from the References/In-Reply-To
/// headers (the root Message-Id) rather than the server-side IMAP THREAD extension, which not
/// every provider implements. Good enough for "show me the other messages in this conversation";
/// upgrade to THREAD/SORT if a target provider needs cross-folder or extension-grade threading.
/// External message ids are "{UID}" scoped to INBOX under the connection's current UIDVALIDITY —
/// they are not stable across a UIDVALIDITY change (rare: a full mailbox reset on the server).
/// </summary>
public sealed class ImapService : IImapService
{
private const int ThreadScanWindow = 200;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
public ImapService(JobTrackerContext db, IDataProtectionProvider protectionProvider)
{
_db = db;
_protector = protectionProvider.CreateProtector("imap-credentials-v1");
}
public async Task<ImapConnectResult> ConnectAsync(string ownerUserId, string host, int port, bool useSsl, string username, string password, CancellationToken cancellationToken)
{
host = host.Trim();
username = username.Trim();
if (host.Length == 0) throw new InvalidOperationException("IMAP host is required.");
if (username.Length == 0) throw new InvalidOperationException("IMAP username is required.");
if (string.IsNullOrEmpty(password)) throw new InvalidOperationException("IMAP password is required.");
// Verify the credentials actually work before persisting them. Failure detail is
// intentionally generic (not the raw MailKit exception) so a "connect" attempt can't be
// used as a distinguishable oracle to fingerprint what's listening on a given host:port.
using (var client = new ImapClient())
{
try
{
await EnsureHostIsExternalAsync(host, cancellationToken);
await client.ConnectAsync(host, port, useSsl, cancellationToken);
await client.AuthenticateAsync(username, password, cancellationToken);
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
throw new InvalidOperationException("Could not connect to that IMAP server with the given credentials. Check host, port, and password.");
}
finally
{
if (client.IsConnected)
{
await client.DisconnectAsync(true, cancellationToken);
}
}
}
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new ImapConnection { OwnerUserId = ownerUserId };
_db.ImapConnections.Add(existing);
}
existing.Host = host;
existing.Port = port;
existing.UseSsl = useSsl;
existing.Username = username;
existing.EncryptedPassword = _protector.Protect(password);
existing.ConnectedAt = DateTimeOffset.UtcNow;
existing.LastSyncStatus = "connected";
existing.LastSyncSource = "connect";
existing.LastSyncMode = "connect";
existing.LastSyncError = null;
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
await _db.SaveChangesAsync(cancellationToken);
return new ImapConnectResult(existing.Username);
}
public Task<ImapConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
=> _db.ImapConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
{
var existing = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null) return;
_db.ImapConnections.Remove(existing);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<ImapMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
try
{
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
var searchQuery = string.IsNullOrWhiteSpace(query)
? SearchQuery.All
: SearchQuery.SubjectContains(query.Trim()).Or(SearchQuery.FromContains(query.Trim())).Or(SearchQuery.BodyContains(query.Trim()));
var uids = await client.Inbox.SearchAsync(searchQuery, cancellationToken);
var window = uids.OrderByDescending(u => u.Id).Take(maxResults).ToList();
var summaries = await FetchSummariesAsync(client, window, cancellationToken);
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return summaries;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<IReadOnlyList<ImapMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string threadKey, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(threadKey))
{
return Array.Empty<ImapMessageSummary>();
}
try
{
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
var recentUids = (await client.Inbox.SearchAsync(SearchQuery.All, cancellationToken))
.OrderByDescending(u => u.Id)
.Take(ThreadScanWindow)
.ToList();
var items = await client.Inbox.FetchAsync(recentUids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
var matches = items.Where(item => ComputeThreadKey(item) == threadKey.Trim()).ToList();
var summaries = matches.Select(ToSummary).OrderBy(s => s.Date).ToList();
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", true, null, cancellationToken);
return summaries;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "reference-scan", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<ImapMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
try
{
using var client = await OpenInboxAsync(ownerUserId, false, cancellationToken);
var uid = ParseUid(messageId);
var summaryItems = await client.Inbox.FetchAsync(new[] { uid }, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
var summary = summaryItems.FirstOrDefault() ?? throw new InvalidOperationException($"IMAP message {messageId} was not found.");
var mime = await client.Inbox.GetMessageAsync(uid, cancellationToken);
var bodyText = mime.TextBody ?? (mime.HtmlBody is null ? "" : StripHtml(mime.HtmlBody));
var attachments = mime.Attachments.Select(a => new ImapMessageAttachment(
a.ContentDisposition?.FileName ?? a.ContentType?.Name,
a.ContentType?.MimeType,
a is MimePart part ? part.Content?.Stream?.Length : null,
a.ContentId,
a.IsAttachment == false
)).ToList();
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", true, null, cancellationToken);
return new ImapMessageDetail(
messageId,
ComputeThreadKey(summary),
summary.Envelope?.Subject ?? "",
FormatAddresses(summary.Envelope?.From),
FormatAddresses(summary.Envelope?.To),
summary.Envelope?.Date,
bodyText.Length > 200 ? bodyText[..200] : bodyText,
bodyText.Trim(),
mime.HtmlBody,
Array.Empty<string>(),
attachments);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "imap-message", false, ex.Message, cancellationToken);
throw;
}
}
private static async Task<IReadOnlyList<ImapMessageSummary>> FetchSummariesAsync(ImapClient client, IList<UniqueId> uids, CancellationToken cancellationToken)
{
if (uids.Count == 0) return Array.Empty<ImapMessageSummary>();
var items = await client.Inbox.FetchAsync(uids, MessageSummaryItems.Envelope | MessageSummaryItems.References | MessageSummaryItems.UniqueId, cancellationToken);
return items.Select(ToSummary).ToList();
}
private static ImapMessageSummary ToSummary(IMessageSummary item) => new(
item.UniqueId.Id.ToString(),
ComputeThreadKey(item),
item.Envelope?.Subject ?? "",
FormatAddresses(item.Envelope?.From),
FormatAddresses(item.Envelope?.To),
item.Envelope?.Date,
"");
// The root Message-Id of the References chain, or this message's own Message-Id if it
// starts no chain — a stand-in "thread id" that works without the IMAP THREAD extension.
private static string ComputeThreadKey(IMessageSummary item)
{
if (item.References is { Count: > 0 })
{
return item.References[0];
}
return item.Envelope?.MessageId ?? item.UniqueId.Id.ToString();
}
private static string FormatAddresses(InternetAddressList? list)
=> list is null ? "" : string.Join(", ", list.Mailboxes.Select(m => m.Address));
private static string StripHtml(string html)
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
private static UniqueId ParseUid(string messageId)
=> uint.TryParse(messageId, out var id) ? new UniqueId(id) : throw new InvalidOperationException($"Invalid IMAP message id: {messageId}");
private async Task<ImapClient> OpenInboxAsync(string ownerUserId, bool writable, CancellationToken cancellationToken)
{
var connection = await _db.ImapConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken)
?? throw new InvalidOperationException("IMAP is not connected for this account.");
string password;
try
{
password = _protector.Unprotect(connection.EncryptedPassword);
}
catch (CryptographicException)
{
throw new InvalidOperationException("Your stored IMAP connection can no longer be decrypted after a server key change. Disconnect and reconnect IMAP.");
}
await EnsureHostIsExternalAsync(connection.Host, cancellationToken);
var client = new ImapClient();
await client.ConnectAsync(connection.Host, connection.Port, connection.UseSsl, cancellationToken);
await client.AuthenticateAsync(connection.Username, password, cancellationToken);
await client.Inbox.OpenAsync(writable ? FolderAccess.ReadWrite : FolderAccess.ReadOnly, cancellationToken);
return client;
}
// SSRF guard: a user-supplied IMAP host resolves to an IP the server then opens a socket to.
// Without this check an authenticated user could point "their mailbox" at loopback, RFC1918/
// link-local ranges, or the cloud metadata address to probe internal infrastructure. Re-run on
// every connect (not just the initial one) so a DNS record that resolves externally at connect
// time can't be rebound internally for a later reconnect.
private static async Task EnsureHostIsExternalAsync(string host, CancellationToken cancellationToken)
{
IPAddress[] addresses;
try
{
addresses = await Dns.GetHostAddressesAsync(host, cancellationToken);
}
catch (SocketException)
{
throw new InvalidOperationException("Could not resolve that IMAP host.");
}
if (addresses.Length == 0 || addresses.Any(IsInternalAddress))
{
throw new InvalidOperationException("That IMAP host is not reachable.");
}
}
private static bool IsInternalAddress(IPAddress address)
{
if (address.IsIPv4MappedToIPv6) address = address.MapToIPv4();
if (IPAddress.IsLoopback(address)) return true;
if (address.Equals(IPAddress.Any) || address.Equals(IPAddress.IPv6Any)) return true;
if (address.AddressFamily == AddressFamily.InterNetwork)
{
var bytes = address.GetAddressBytes();
if (bytes[0] == 10) return true; // 10.0.0.0/8
if (bytes[0] == 172 && bytes[1] >= 16 && bytes[1] <= 31) return true; // 172.16.0.0/12
if (bytes[0] == 192 && bytes[1] == 168) return true; // 192.168.0.0/16
if (bytes[0] == 169 && bytes[1] == 254) return true; // 169.254.0.0/16 (incl. cloud metadata)
if (bytes[0] == 127) return true; // 127.0.0.0/8
return false;
}
if (address.AddressFamily == AddressFamily.InterNetworkV6)
{
if (address.IsIPv6LinkLocal || address.IsIPv6SiteLocal) return true;
var bytes = address.GetAddressBytes();
if ((bytes[0] & 0xFE) == 0xFC) return true; // fc00::/7 (unique local)
return false;
}
return true; // unknown address family: fail closed
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.ImapConnections.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);
}
}
@@ -0,0 +1,507 @@
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text.Json;
using JobTrackerApi.Data;
using JobTrackerApi.Models;
using Microsoft.AspNetCore.DataProtection;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Caching.Memory;
namespace JobTrackerApi.Services;
public interface IMicrosoftGraphOAuthService
{
string BuildAuthorizationUrl(string ownerUserId, string redirectUri);
string? ConsumeState(string state);
Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken);
Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken);
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken);
Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record MicrosoftGraphOAuthExchangeResult(string MailAddress);
public sealed record MicrosoftGraphMessageSummary(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record MicrosoftGraphMessageAttachment(string? FileName, string? MimeType, long? SizeBytes, string? GraphAttachmentId, bool Inline);
public sealed record MicrosoftGraphMessageDetail(string Id, string ConversationId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml, IReadOnlyList<string> Labels, IReadOnlyList<MicrosoftGraphMessageAttachment> Attachments);
internal sealed class MicrosoftGraphTokenResponse
{
public string? access_token { get; set; }
public int expires_in { get; set; }
public string? refresh_token { get; set; }
public string? scope { get; set; }
public string? token_type { get; set; }
}
/// <summary>
/// Outlook / Microsoft 365 mail via Microsoft Graph. Mirrors <see cref="GmailOAuthService"/>'s shape
/// (auth-code + offline refresh, encrypted token storage, per-owner connection row) so the two providers
/// stay structurally interchangeable behind <see cref="EmailProviders.IEmailProvider"/>.
/// </summary>
public sealed class MicrosoftGraphOAuthService : IMicrosoftGraphOAuthService
{
private const string Scope = "openid email profile offline_access https://graph.microsoft.com/Mail.Read";
private readonly IConfiguration _cfg;
private readonly JobTrackerContext _db;
private readonly IDataProtector _protector;
private readonly IHttpClientFactory _httpClientFactory;
private readonly IMemoryCache _cache;
public MicrosoftGraphOAuthService(
IConfiguration cfg,
JobTrackerContext db,
IDataProtectionProvider protectionProvider,
IHttpClientFactory httpClientFactory,
IMemoryCache cache)
{
_cfg = cfg;
_db = db;
_protector = protectionProvider.CreateProtector("microsoft-graph-oauth-tokens-v1");
_httpClientFactory = httpClientFactory;
_cache = cache;
}
public string BuildAuthorizationUrl(string ownerUserId, string redirectUri)
{
var clientId = GetRequiredClientId();
var state = Convert.ToBase64String(Guid.NewGuid().ToByteArray())
.Replace("+", "-")
.Replace("/", "_")
.TrimEnd('=');
_cache.Set(GetStateCacheKey(state), ownerUserId, TimeSpan.FromMinutes(15));
var query = new Dictionary<string, string?>
{
["client_id"] = clientId,
["redirect_uri"] = redirectUri,
["response_type"] = "code",
["response_mode"] = "query",
["scope"] = Scope,
["state"] = state,
};
var encoded = string.Join("&", query.Select(kvp => $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? "")}"));
return $"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/authorize?{encoded}";
}
public async Task<MicrosoftGraphOAuthExchangeResult> ExchangeCodeAsync(string ownerUserId, string code, string redirectUri, CancellationToken cancellationToken)
{
var tokens = await ExchangeCodeForTokensAsync(code, redirectUri, cancellationToken);
var accessToken = tokens.access_token?.Trim();
var refreshToken = tokens.refresh_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Microsoft did not return an access token.");
if (string.IsNullOrWhiteSpace(refreshToken))
throw new InvalidOperationException("Microsoft did not return a refresh token. Reconnect Outlook and ensure offline_access consent is granted.");
var mailAddress = await GetProfileAsync(accessToken, cancellationToken);
if (string.IsNullOrWhiteSpace(mailAddress))
throw new InvalidOperationException("Microsoft did not return a mail address.");
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null)
{
existing = new MicrosoftGraphConnection
{
OwnerUserId = ownerUserId,
};
_db.MicrosoftGraphConnections.Add(existing);
}
existing.MailAddress = mailAddress.Trim();
existing.EncryptedRefreshToken = _protector.Protect(refreshToken);
existing.EncryptedAccessToken = _protector.Protect(accessToken);
existing.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(tokens.expires_in - 60, 60));
existing.Scope = tokens.scope?.Trim() ?? Scope;
existing.ConnectedAt = DateTimeOffset.UtcNow;
existing.LastSyncStatus = "connected";
existing.LastSyncSource = "oauth-callback";
existing.LastSyncMode = "connect";
existing.LastSyncError = null;
existing.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
existing.LastSyncSucceededAt = existing.LastSyncAttemptedAt;
await _db.SaveChangesAsync(cancellationToken);
return new MicrosoftGraphOAuthExchangeResult(existing.MailAddress);
}
public string? ConsumeState(string state)
{
if (!_cache.TryGetValue<string>(GetStateCacheKey(state), out var ownerUserId))
{
return null;
}
_cache.Remove(GetStateCacheKey(state));
return ownerUserId;
}
public Task<MicrosoftGraphConnection?> GetConnectionAsync(string ownerUserId, CancellationToken cancellationToken)
{
return _db.MicrosoftGraphConnections.AsNoTracking().FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
}
public async Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken)
{
var existing = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (existing is null) return;
_db.MicrosoftGraphConnections.Remove(existing);
await _db.SaveChangesAsync(cancellationToken);
}
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken)
{
maxResults = Math.Clamp(maxResults, 1, 25);
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
var url = $"https://graph.microsoft.com/v1.0/me/messages?$top={maxResults}&$select={select}&$orderby=receivedDateTime desc";
if (!string.IsNullOrWhiteSpace(query))
{
client.DefaultRequestHeaders.Add("ConsistencyLevel", "eventual");
url += $"&$search={Uri.EscapeDataString(EscapeSearchQuery(query.Trim()))}";
}
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var results = ReadMessageSummaries(doc.RootElement);
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "list-messages", string.IsNullOrWhiteSpace(query) ? "default-query" : "custom-query", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<IReadOnlyList<MicrosoftGraphMessageSummary>> ListThreadMessagesAsync(string ownerUserId, string conversationId, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(conversationId))
{
return Array.Empty<MicrosoftGraphMessageSummary>();
}
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview";
var filter = $"conversationId eq '{conversationId.Trim().Replace("'", "''")}'";
var url = $"https://graph.microsoft.com/v1.0/me/messages?$filter={Uri.EscapeDataString(filter)}&$select={select}&$orderby=receivedDateTime asc";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var results = ReadMessageSummaries(doc.RootElement);
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", true, null, cancellationToken);
return results;
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "thread-refresh", "conversation-metadata", false, ex.Message, cancellationToken);
throw;
}
}
public async Task<MicrosoftGraphMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken)
{
try
{
var accessToken = await GetValidAccessTokenAsync(ownerUserId, cancellationToken);
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
var select = "id,conversationId,subject,from,toRecipients,receivedDateTime,bodyPreview,body,categories,hasAttachments";
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}?$select={select}";
using var response = await client.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
var root = doc.RootElement;
var conversationId = root.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "";
var snippet = root.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : "";
var categories = root.TryGetProperty("categories", out var categoriesEl) && categoriesEl.ValueKind == JsonValueKind.Array
? categoriesEl.EnumerateArray().Where(x => x.ValueKind == JsonValueKind.String).Select(x => x.GetString()).Where(x => !string.IsNullOrWhiteSpace(x)).Cast<string>().ToList()
: new List<string>();
string bodyText = "";
string? bodyHtml = null;
if (root.TryGetProperty("body", out var bodyEl) && bodyEl.ValueKind == JsonValueKind.Object)
{
var contentType = bodyEl.TryGetProperty("contentType", out var ctEl) ? ctEl.GetString() ?? "" : "";
var content = bodyEl.TryGetProperty("content", out var contentEl) ? contentEl.GetString() ?? "" : "";
if (string.Equals(contentType, "html", StringComparison.OrdinalIgnoreCase))
{
bodyHtml = content;
bodyText = StripHtml(content);
}
else
{
bodyText = content;
}
}
var attachments = root.TryGetProperty("hasAttachments", out var hasAttEl) && hasAttEl.ValueKind == JsonValueKind.True
? await ListAttachmentsAsync(client, messageId, cancellationToken)
: Array.Empty<MicrosoftGraphMessageAttachment>();
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", true, null, cancellationToken);
return new MicrosoftGraphMessageDetail(
messageId,
conversationId,
ReadSubject(root),
ReadRecipientAddress(root, "from"),
ReadRecipientListAddresses(root, "toRecipients"),
ReadReceivedDate(root),
snippet,
bodyText.Trim(),
bodyHtml,
categories,
attachments);
}
catch (Exception ex)
{
await TouchSyncStateAsync(ownerUserId, "message-detail", "graph-message", false, ex.Message, cancellationToken);
throw;
}
}
private static async Task<IReadOnlyList<MicrosoftGraphMessageAttachment>> ListAttachmentsAsync(HttpClient client, string messageId, CancellationToken cancellationToken)
{
var url = $"https://graph.microsoft.com/v1.0/me/messages/{Uri.EscapeDataString(messageId)}/attachments?$select=id,name,contentType,size,isInline";
using var response = await client.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
return Array.Empty<MicrosoftGraphMessageAttachment>();
}
using var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync(cancellationToken), cancellationToken: cancellationToken);
if (!doc.RootElement.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
{
return Array.Empty<MicrosoftGraphMessageAttachment>();
}
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageAttachment(
item.TryGetProperty("name", out var nameEl) ? nameEl.GetString() : null,
item.TryGetProperty("contentType", out var typeEl) ? typeEl.GetString() : null,
item.TryGetProperty("size", out var sizeEl) && sizeEl.TryGetInt64(out var size) ? size : null,
item.TryGetProperty("id", out var idEl) ? idEl.GetString() : null,
item.TryGetProperty("isInline", out var inlineEl) && inlineEl.ValueKind == JsonValueKind.True
)).ToList();
}
private static List<MicrosoftGraphMessageSummary> ReadMessageSummaries(JsonElement root)
{
if (!root.TryGetProperty("value", out var valueEl) || valueEl.ValueKind != JsonValueKind.Array)
{
return new List<MicrosoftGraphMessageSummary>();
}
return valueEl.EnumerateArray().Select(item => new MicrosoftGraphMessageSummary(
item.TryGetProperty("id", out var idEl) ? idEl.GetString() ?? "" : "",
item.TryGetProperty("conversationId", out var convEl) ? convEl.GetString() ?? "" : "",
ReadSubject(item),
ReadRecipientAddress(item, "from"),
ReadRecipientListAddresses(item, "toRecipients"),
ReadReceivedDate(item),
item.TryGetProperty("bodyPreview", out var previewEl) ? previewEl.GetString() ?? "" : ""
)).Where(m => !string.IsNullOrWhiteSpace(m.Id)).ToList();
}
private static string ReadSubject(JsonElement item)
=> item.TryGetProperty("subject", out var subjectEl) ? subjectEl.GetString() ?? "" : "";
private static DateTimeOffset? ReadReceivedDate(JsonElement item)
=> item.TryGetProperty("receivedDateTime", out var dateEl) && dateEl.ValueKind == JsonValueKind.String
&& DateTimeOffset.TryParse(dateEl.GetString(), out var parsed) ? parsed : null;
private static string ReadRecipientAddress(JsonElement item, string property)
{
if (!item.TryGetProperty(property, out var recipientEl) || recipientEl.ValueKind != JsonValueKind.Object) return "";
if (!recipientEl.TryGetProperty("emailAddress", out var addressEl) || addressEl.ValueKind != JsonValueKind.Object) return "";
return addressEl.TryGetProperty("address", out var addrEl) ? addrEl.GetString() ?? "" : "";
}
private static string ReadRecipientListAddresses(JsonElement item, string property)
{
if (!item.TryGetProperty(property, out var listEl) || listEl.ValueKind != JsonValueKind.Array) return "";
var addresses = listEl.EnumerateArray()
.Where(r => r.TryGetProperty("emailAddress", out _))
.Select(r => r.GetProperty("emailAddress").TryGetProperty("address", out var a) ? a.GetString() : null)
.Where(a => !string.IsNullOrWhiteSpace(a));
return string.Join(", ", addresses);
}
private static string EscapeSearchQuery(string query) => "\"" + query.Replace("\"", "'") + "\"";
private static string StripHtml(string html)
=> System.Text.RegularExpressions.Regex.Replace(html, "<[^>]+>", " ").Trim();
private async Task<string> GetValidAccessTokenAsync(string ownerUserId, CancellationToken cancellationToken)
{
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null)
throw new InvalidOperationException("Outlook is not connected for this account.");
if (!string.IsNullOrWhiteSpace(connection.EncryptedAccessToken) &&
connection.AccessTokenExpiresAt is { } expiresAt &&
expiresAt > DateTimeOffset.UtcNow.AddMinutes(1))
{
try
{
return _protector.Unprotect(connection.EncryptedAccessToken);
}
catch (CryptographicException)
{
connection.EncryptedAccessToken = null;
connection.AccessTokenExpiresAt = null;
}
}
string refreshToken;
try
{
refreshToken = _protector.Unprotect(connection.EncryptedRefreshToken);
}
catch (CryptographicException)
{
throw new InvalidOperationException("Your stored Outlook connection can no longer be decrypted after a server key change. Disconnect Outlook and connect it again.");
}
var refreshed = await RefreshAccessTokenAsync(refreshToken, cancellationToken);
var accessToken = refreshed.access_token?.Trim();
if (string.IsNullOrWhiteSpace(accessToken))
throw new InvalidOperationException("Failed to refresh Outlook access token.");
connection.EncryptedAccessToken = _protector.Protect(accessToken);
connection.AccessTokenExpiresAt = DateTimeOffset.UtcNow.AddSeconds(Math.Max(refreshed.expires_in - 60, 60));
if (!string.IsNullOrWhiteSpace(refreshed.scope))
{
connection.Scope = refreshed.scope.Trim();
}
if (!string.IsNullOrWhiteSpace(refreshed.refresh_token))
{
connection.EncryptedRefreshToken = _protector.Protect(refreshed.refresh_token.Trim());
}
await _db.SaveChangesAsync(cancellationToken);
return accessToken;
}
private async Task<MicrosoftGraphTokenResponse> ExchangeCodeForTokensAsync(string code, string redirectUri, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["code"] = code,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["redirect_uri"] = redirectUri,
["grant_type"] = "authorization_code",
["scope"] = Scope,
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Microsoft token exchange failed: {payload}");
}
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Microsoft token response.");
}
private async Task<MicrosoftGraphTokenResponse> RefreshAccessTokenAsync(string refreshToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
using var response = await client.PostAsync(
$"https://login.microsoftonline.com/{GetTenant()}/oauth2/v2.0/token",
new FormUrlEncodedContent(new Dictionary<string, string>
{
["refresh_token"] = refreshToken,
["client_id"] = GetRequiredClientId(),
["client_secret"] = GetRequiredClientSecret(),
["grant_type"] = "refresh_token",
["scope"] = Scope,
}),
cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Microsoft token refresh failed: {payload}");
}
return JsonSerializer.Deserialize<MicrosoftGraphTokenResponse>(payload)
?? throw new InvalidOperationException("Unable to parse Microsoft refresh response.");
}
private async Task<string> GetProfileAsync(string accessToken, CancellationToken cancellationToken)
{
var client = _httpClientFactory.CreateClient();
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
using var response = await client.GetAsync("https://graph.microsoft.com/v1.0/me?$select=mail,userPrincipalName", cancellationToken);
var payload = await response.Content.ReadAsStringAsync(cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new InvalidOperationException($"Failed to read Microsoft profile: {payload}");
}
using var doc = JsonDocument.Parse(payload);
var mail = doc.RootElement.TryGetProperty("mail", out var mailEl) ? mailEl.GetString() : null;
if (!string.IsNullOrWhiteSpace(mail)) return mail;
return doc.RootElement.TryGetProperty("userPrincipalName", out var upnEl) ? upnEl.GetString() ?? "" : "";
}
private async Task TouchSyncStateAsync(string ownerUserId, string mode, string source, bool succeeded, string? error, CancellationToken cancellationToken)
{
var connection = await _db.MicrosoftGraphConnections.FirstOrDefaultAsync(x => x.OwnerUserId == ownerUserId, cancellationToken);
if (connection is null) return;
connection.LastSyncAttemptedAt = DateTimeOffset.UtcNow;
connection.LastSyncMode = mode;
connection.LastSyncSource = source;
connection.LastSyncStatus = succeeded ? "ok" : "error";
connection.LastSyncError = succeeded ? null : error;
if (succeeded)
{
connection.LastSyncedAt = DateTimeOffset.UtcNow;
connection.LastSyncSucceededAt = connection.LastSyncedAt;
}
await _db.SaveChangesAsync(cancellationToken);
}
private string GetTenant() => (_cfg["Microsoft:TenantId"] ?? "common").Trim();
private string GetRequiredClientId()
{
var value = (_cfg["Microsoft:ClientId"] ?? "").Trim();
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientId is not configured.");
}
private string GetRequiredClientSecret()
{
var value = (_cfg["Microsoft:ClientSecret"] ?? "").Trim();
return value.Length > 0 ? value : throw new InvalidOperationException("Microsoft:ClientSecret is not configured.");
}
private static string GetStateCacheKey(string state) => $"microsoft-oauth-state:{state}";
}
@@ -9,6 +9,186 @@ namespace JobTrackerApi.Services;
public static class StartupInitializationExtensions
{
// SQLite-dialect schema helpers. Promoted from local functions to class-level statics so a
// second reconciliation pass can run after Migrate() creates the base tables on a brand-new
// database (see the CoreSchemaReady-adjacent block near the end of InitializeJobTrackerAsync):
// the ad-hoc EnsureColumn calls below no-op on a table that doesn't exist yet, so a genuinely
// fresh boot needs them re-run once Migrate() has created JobApplications/Correspondences.
private static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
private static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
private static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
private static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
// Ad-hoc columns/backfills added over time without a matching EF migration (the reason the
// ModelSnapshot drifted -- see the SyncModelSnapshot migration's doc comment). Safe to call
// any number of times against any connection state: every check no-ops if the table or
// column doesn't exist yet or already matches.
private static void ReconcileCoreAppColumns(DbConnection conn)
{
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Provider", "ALTER TABLE Correspondences ADD COLUMN Provider TEXT NULL;");
if (HasTable(conn, "Correspondences"))
{
Exec(conn, "UPDATE Correspondences SET Provider = 'gmail' WHERE Provider IS NULL AND ExternalThreadId IS NOT NULL;");
Exec(conn, "UPDATE Correspondences SET Provider = 'manual' WHERE Provider IS NULL;");
}
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
}
// MySQL/MariaDB-dialect equivalents of the helpers above.
private static bool HasMySqlTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
return cmd.ExecuteScalar() is not null;
}
private static bool MySqlColumnExists(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
private static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
{
if (!HasMySqlTable(c, table)) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
// MySQL mirror of ReconcileCoreAppColumns -- same rationale (re-run after Migrate() on a
// brand-new database, where these tables didn't exist yet during the pre-Migrate pass).
private static void ReconcileCoreAppColumnsMySql(DbConnection conn)
{
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Provider", "ALTER TABLE `Correspondences` ADD COLUMN `Provider` varchar(50) NULL;");
if (HasMySqlTable(conn, "Correspondences"))
{
using (var backfillGmail = conn.CreateCommand())
{
backfillGmail.CommandText = "UPDATE `Correspondences` SET `Provider` = 'gmail' WHERE `Provider` IS NULL AND `ExternalThreadId` IS NOT NULL;";
backfillGmail.ExecuteNonQuery();
}
using (var backfillManual = conn.CreateCommand())
{
backfillManual.CommandText = "UPDATE `Correspondences` SET `Provider` = 'manual' WHERE `Provider` IS NULL;";
backfillManual.ExecuteNonQuery();
}
}
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
}
public static Task InitializeJobTrackerAsync(this WebApplication app)
{
// Apply EF migrations on startup (SQLite dev DB lives in the repo).
@@ -130,50 +310,6 @@ public static class StartupInitializationExtensions
using DbConnection conn = db.Database.GetDbConnection();
conn.Open();
static bool HasTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM sqlite_master WHERE type='table' AND name=$name LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$name";
p.Value = table;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static bool HasColumn(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = $"SELECT 1 FROM pragma_table_info('{table}') WHERE name = '{column}' LIMIT 1;";
return cmd.ExecuteScalar() is not null;
}
static bool HasMigration(DbConnection c, string migrationId)
{
if (!HasTable(c, "__EFMigrationsHistory")) return false;
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM __EFMigrationsHistory WHERE MigrationId=$id LIMIT 1;";
var p = cmd.CreateParameter();
p.ParameterName = "$id";
p.Value = migrationId;
cmd.Parameters.Add(p);
return cmd.ExecuteScalar() is not null;
}
static void Exec(DbConnection c, string sql)
{
using var cmd = c.CreateCommand();
cmd.CommandText = sql;
cmd.ExecuteNonQuery();
}
static void EnsureColumn(DbConnection c, string table, string column, string ddl)
{
// Fresh databases won't have the table until EF migrations run.
if (!HasTable(c, table)) return;
if (!HasColumn(c, table, column)) Exec(c, ddl);
}
static void EnsureIdentityTables(DbConnection c)
{
// EF migrations are used for the app schema. In some environments `dotnet ef` isnt available,
@@ -360,6 +496,57 @@ 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 EnsureImapConnectionsTable(DbConnection c)
{
Exec(c, """
CREATE TABLE IF NOT EXISTS "ImapConnections" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_ImapConnections" PRIMARY KEY AUTOINCREMENT,
"OwnerUserId" TEXT NOT NULL,
"Host" TEXT NOT NULL,
"Port" INTEGER NOT NULL,
"UseSsl" INTEGER NOT NULL,
"Username" TEXT NOT NULL,
"EncryptedPassword" 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 UNIQUE INDEX IF NOT EXISTS "IX_ImapConnections_OwnerUserId" ON "ImapConnections" ("OwnerUserId");""");
}
static void EnsureCvTables(DbConnection c)
{
Exec(c, """
@@ -428,6 +615,8 @@ public static class StartupInitializationExtensions
}
EnsureGmailConnectionsTable(conn);
EnsureMicrosoftGraphConnectionsTable(conn);
EnsureImapConnectionsTable(conn);
EnsureCvTables(conn);
// Legacy DB signature: migration history exists (AddCorrespondence applied), but 20260310195000 not recorded,
@@ -477,33 +666,10 @@ public static class StartupInitializationExtensions
}
// Some dev DBs may not match the "legacy" fingerprint above but still lack
// the ShortSummary column. Ensure it exists unconditionally if missing.
EnsureColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE JobApplications ADD COLUMN ShortSummary TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE JobApplications ADD COLUMN TailoredCvText TEXT NULL;");
EnsureColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE JobApplications ADD COLUMN TailoredCvUpdatedAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE JobApplications ADD COLUMN LastReminderEmailSentAt TEXT NULL;");
EnsureColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE JobApplications ADD COLUMN RecruiterMessageDraft TEXT NULL;");
// Structured salary fields (EF maps decimal to TEXT on SQLite).
EnsureColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE JobApplications ADD COLUMN SalaryMin TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE JobApplications ADD COLUMN SalaryMax TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE JobApplications ADD COLUMN SalaryCurrency TEXT NULL;");
EnsureColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE JobApplications ADD COLUMN SalaryPeriod TEXT NULL;");
// Ensure ownership columns exist even on non-legacy DBs.
EnsureColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE Companies ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE JobApplications ADD COLUMN OwnerUserId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Subject", "ALTER TABLE Correspondences ADD COLUMN Subject TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Channel", "ALTER TABLE Correspondences ADD COLUMN Channel TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE Correspondences ADD COLUMN ExternalMessageId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE Correspondences ADD COLUMN ExternalThreadId TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE Correspondences ADD COLUMN ExternalFrom TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE Correspondences ADD COLUMN ExternalTo TEXT NULL;");
EnsureColumn(conn, "Correspondences", "Direction", "ALTER TABLE Correspondences ADD COLUMN Direction TEXT NULL;");
EnsureColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE Correspondences ADD COLUMN ExternalLabelsJson TEXT NULL;");
EnsureColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE Correspondences ADD COLUMN AttachmentMetadataJson TEXT NULL;");
EnsureColumn(conn, "Attachments", "Purpose", "ALTER TABLE Attachments ADD COLUMN Purpose TEXT NULL;");
EnsureColumn(conn, "Attachments", "UseForAi", "ALTER TABLE Attachments ADD COLUMN UseForAi INTEGER NOT NULL DEFAULT 1;");
// later ad-hoc columns. Ensure them unconditionally if missing (also re-run once
// more after Migrate() below, in case this is a brand-new DB where these tables
// didn't exist yet at this point).
ReconcileCoreAppColumns(conn);
// Hot-path composite indexes for tenant-scoped list/board/stats/analytics
// (OwnerUserId + IsDeleted) and reminders (OwnerUserId + FollowUpAt). Guarded
@@ -527,18 +693,6 @@ public static class StartupInitializationExtensions
conn.Open();
EnsureIdentityTablesMySql(conn);
static bool MySqlColumnExists(DbConnection c, string table, string column)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.COLUMNS WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table AND COLUMN_NAME = @column LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
var p3 = cmd.CreateParameter(); p3.ParameterName = "@column"; p3.Value = column; cmd.Parameters.Add(p3);
return cmd.ExecuteScalar() is not null;
}
static bool MySqlIndexExists(DbConnection c, string table, string indexName)
{
using var cmd = c.CreateCommand();
@@ -551,28 +705,6 @@ public static class StartupInitializationExtensions
return cmd.ExecuteScalar() is not null;
}
static bool HasMySqlTable(DbConnection c, string table)
{
using var cmd = c.CreateCommand();
cmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var p1 = cmd.CreateParameter(); p1.ParameterName = "@schema"; p1.Value = c.Database; cmd.Parameters.Add(p1);
var p2 = cmd.CreateParameter(); p2.ParameterName = "@table"; p2.Value = table; cmd.Parameters.Add(p2);
return cmd.ExecuteScalar() is not null;
}
static void EnsureMySqlColumn(DbConnection c, string table, string column, string ddl)
{
using var existsCmd = c.CreateCommand();
existsCmd.CommandText = "SELECT 1 FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_SCHEMA = @schema AND TABLE_NAME = @table LIMIT 1;";
var ep1 = existsCmd.CreateParameter(); ep1.ParameterName = "@schema"; ep1.Value = c.Database; existsCmd.Parameters.Add(ep1);
var ep2 = existsCmd.CreateParameter(); ep2.ParameterName = "@table"; ep2.Value = table; existsCmd.Parameters.Add(ep2);
if (existsCmd.ExecuteScalar() is null) return;
if (MySqlColumnExists(c, table, column)) return;
using var ddlCmd = c.CreateCommand();
ddlCmd.CommandText = ddl;
ddlCmd.ExecuteNonQuery();
}
static bool MySqlIntPrimaryKeyIsAutoIncrement(DbConnection c, string table, string column)
{
@@ -606,56 +738,16 @@ public static class StartupInitializationExtensions
EnsureMySqlAutoIncrementPrimaryKey(conn, "Attachments", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "JobEvents", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "GmailConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "MicrosoftGraphConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "ImapConnections", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvUploadArtifacts", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "CvExtractionRuns", "Id");
EnsureMySqlAutoIncrementPrimaryKey(conn, "TailoredCvDrafts", "Id");
EnsureMySqlColumn(conn, "Companies", "OwnerUserId", "ALTER TABLE `Companies` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "Companies", "Source", "ALTER TABLE `Companies` ADD COLUMN `Source` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterName", "ALTER TABLE `Companies` ADD COLUMN `RecruiterName` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterEmail", "ALTER TABLE `Companies` ADD COLUMN `RecruiterEmail` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "RecruiterLinkedIn", "ALTER TABLE `Companies` ADD COLUMN `RecruiterLinkedIn` longtext NULL;");
EnsureMySqlColumn(conn, "Companies", "LastContactedAt", "ALTER TABLE `Companies` ADD COLUMN `LastContactedAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "NextContactAt", "ALTER TABLE `Companies` ADD COLUMN `NextContactAt` datetime NULL;");
EnsureMySqlColumn(conn, "Companies", "PipelineStage", "ALTER TABLE `Companies` ADD COLUMN `PipelineStage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "OwnerUserId", "ALTER TABLE `JobApplications` ADD COLUMN `OwnerUserId` varchar(255) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "IsDeleted", "ALTER TABLE `JobApplications` ADD COLUMN `IsDeleted` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "DeletedAt", "ALTER TABLE `JobApplications` ADD COLUMN `DeletedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Location", "ALTER TABLE `JobApplications` ADD COLUMN `Location` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Salary", "ALTER TABLE `JobApplications` ADD COLUMN `Salary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMin", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMin` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryMax", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryMax` decimal(18,2) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryCurrency", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryCurrency` varchar(8) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "SalaryPeriod", "ALTER TABLE `JobApplications` ADD COLUMN `SalaryPeriod` varchar(16) NULL;");
EnsureMySqlColumn(conn, "JobApplications", "NextAction", "ALTER TABLE `JobApplications` ADD COLUMN `NextAction` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FollowUpAt", "ALTER TABLE `JobApplications` ADD COLUMN `FollowUpAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "FeedbackRequestedAt", "ALTER TABLE `JobApplications` ADD COLUMN `FeedbackRequestedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "RecruiterMessageDraft", "ALTER TABLE `JobApplications` ADD COLUMN `RecruiterMessageDraft` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseReceived", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseReceived` tinyint(1) NOT NULL DEFAULT 0;");
EnsureMySqlColumn(conn, "JobApplications", "ResponseDate", "ALTER TABLE `JobApplications` ADD COLUMN `ResponseDate` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Notes", "ALTER TABLE `JobApplications` ADD COLUMN `Notes` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "CoverLetterText", "ALTER TABLE `JobApplications` ADD COLUMN `CoverLetterText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "JobUrl", "ALTER TABLE `JobApplications` ADD COLUMN `JobUrl` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Description", "ALTER TABLE `JobApplications` ADD COLUMN `Description` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TranslatedDescription", "ALTER TABLE `JobApplications` ADD COLUMN `TranslatedDescription` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "DescriptionLanguage", "ALTER TABLE `JobApplications` ADD COLUMN `DescriptionLanguage` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Tags", "ALTER TABLE `JobApplications` ADD COLUMN `Tags` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "Deadline", "ALTER TABLE `JobApplications` ADD COLUMN `Deadline` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "ShortSummary", "ALTER TABLE `JobApplications` ADD COLUMN `ShortSummary` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvText", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvText` longtext NULL;");
EnsureMySqlColumn(conn, "JobApplications", "TailoredCvUpdatedAt", "ALTER TABLE `JobApplications` ADD COLUMN `TailoredCvUpdatedAt` datetime NULL;");
EnsureMySqlColumn(conn, "JobApplications", "LastReminderEmailSentAt", "ALTER TABLE `JobApplications` ADD COLUMN `LastReminderEmailSentAt` datetime NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Subject", "ALTER TABLE `Correspondences` ADD COLUMN `Subject` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Channel", "ALTER TABLE `Correspondences` ADD COLUMN `Channel` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalMessageId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalMessageId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalThreadId", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalThreadId` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalFrom", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalFrom` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalTo", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalTo` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "Direction", "ALTER TABLE `Correspondences` ADD COLUMN `Direction` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Correspondences", "ExternalLabelsJson", "ALTER TABLE `Correspondences` ADD COLUMN `ExternalLabelsJson` longtext NULL;");
EnsureMySqlColumn(conn, "Correspondences", "AttachmentMetadataJson", "ALTER TABLE `Correspondences` ADD COLUMN `AttachmentMetadataJson` longtext NULL;");
EnsureMySqlColumn(conn, "Attachments", "Purpose", "ALTER TABLE `Attachments` ADD COLUMN `Purpose` varchar(100) NULL;");
EnsureMySqlColumn(conn, "Attachments", "UseForAi", "ALTER TABLE `Attachments` ADD COLUMN `UseForAi` tinyint(1) NOT NULL DEFAULT 1;");
// Ad-hoc columns for the tables Migrate() creates (Companies/JobApplications/
// Correspondences/Attachments) -- re-run once more after Migrate() below via
// ReconcileCoreAppColumnsMySql, in case this is a brand-new database.
ReconcileCoreAppColumnsMySql(conn);
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvText", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvText` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "ProfileCvStructureJson", "ALTER TABLE `AspNetUsers` ADD COLUMN `ProfileCvStructureJson` longtext NULL;");
EnsureMySqlColumn(conn, "AspNetUsers", "CurrentCvUploadArtifactId", "ALTER TABLE `AspNetUsers` ADD COLUMN `CurrentCvUploadArtifactId` int NULL;");
@@ -799,6 +891,54 @@ 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, "ImapConnections"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = @"CREATE TABLE IF NOT EXISTS `ImapConnections` (
`Id` int NOT NULL AUTO_INCREMENT,
`OwnerUserId` varchar(255) NOT NULL,
`Host` varchar(255) NOT NULL,
`Port` int NOT NULL,
`UseSsl` tinyint(1) NOT NULL,
`Username` varchar(255) NOT NULL,
`EncryptedPassword` 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 +1030,27 @@ 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, "ImapConnections", "IX_ImapConnections_OwnerUserId"))
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "CREATE UNIQUE INDEX `IX_ImapConnections_OwnerUserId` ON `ImapConnections` (`OwnerUserId`);";
cmd.ExecuteNonQuery();
}
if (!MySqlIndexExists(conn, "TailoredCvDrafts", "IX_TailoredCvDrafts_OwnerUserId_JobApplicationId"))
{
using var cmd = conn.CreateCommand();
@@ -1044,6 +1205,20 @@ public static class StartupInitializationExtensions
app.Logger.LogWarning("Core schema is incomplete after startup initialization. Background services will remain paused until required tables exist.");
return Task.CompletedTask;
}
// On a brand-new database, the ad-hoc-column reconciliation above ran before
// Migrate() created JobApplications/Correspondences, so every EnsureColumn call
// no-opped. Now that CoreSchemaReady confirms the tables exist (created either just
// now by Migrate(), or already, on a prior boot), re-run it -- idempotent, so this is
// free on every boot except the very first one, where it's required.
if (runtimeProvider is "mysql" or "mariadb")
{
ReconcileCoreAppColumnsMySql(conn);
}
else
{
ReconcileCoreAppColumns(conn);
}
}
var readiness = app.Services.GetRequiredService<IStartupReadiness>();
@@ -29,5 +29,6 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.14" />
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
<PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="8.14.0" />
<PackageReference Include="MailKit" Version="4.17.0" />
</ItemGroup>
</Project>
+4
View File
@@ -21,6 +21,10 @@ namespace JobTrackerApi.Models
public string? ExternalTo { get; set; }
public string? ExternalLabelsJson { get; set; }
public string? AttachmentMetadataJson { get; set; }
// Provider discriminator: "gmail" | "microsoft" | "imap" | "manual". Set at the write
// site (import controller or the manual-entry endpoint), not inferred from other fields,
// so it stays correct even for hand-entered rows that happen to carry external-looking data.
public string? Provider { get; set; }
public string Content { get; set; } = "";
public DateTime Date { get; set; } = DateTime.Now;
+20
View File
@@ -0,0 +1,20 @@
namespace JobTrackerApi.Models;
public sealed class ImapConnection
{
public int Id { get; set; }
public string OwnerUserId { get; set; } = "";
public string Host { get; set; } = "";
public int Port { get; set; } = 993;
public bool UseSsl { get; set; } = true;
public string Username { get; set; } = "";
public string EncryptedPassword { 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; }
}
+20
View File
@@ -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; }
}
+5
View File
@@ -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)
+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="/login" replace state={{ from: path }} />;
if (requireAuth && !me) return <Navigate to="/" replace state={{ from: path }} />;
const pageTitle = titleFor(path, t);
const breadcrumbs = breadcrumbsFor(path, t);
@@ -0,0 +1,49 @@
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();
});
});
@@ -0,0 +1,243 @@
import React, { useCallback, useEffect, useState } from "react";
import { Box, Button, Checkbox, Chip, Divider, FormControlLabel, Paper, Stack, TextField, Typography } from "@mui/material";
import CheckCircleIcon from "@mui/icons-material/CheckCircle";
import { api, getApiErrorMessage } from "../api";
import { useToast } from "../toast";
import type { GmailStatus, ImapStatus, MicrosoftGraphStatus } from "../types";
// Settings > Account: connect/disconnect each linked-mailbox provider. Gmail and Microsoft use
// the same OAuth-popup + postMessage handshake (mirrored server-side in GmailController /
// MicrosoftGraphController's BuildPopupHtml); IMAP has no OAuth step, so it's a plain credential
// form submitted to POST /api/imap/connect, which verifies the connection before storing it.
export default function EmailProviderConnections() {
const { toast } = useToast();
const [gmailStatus, setGmailStatus] = useState<GmailStatus | null>(null);
const [microsoftStatus, setMicrosoftStatus] = useState<MicrosoftGraphStatus | null>(null);
const [imapStatus, setImapStatus] = useState<ImapStatus | null>(null);
const [imapForm, setImapForm] = useState({ host: "", port: 993, useSsl: true, username: "", password: "" });
const [imapConnecting, setImapConnecting] = useState(false);
const loadGmailStatus = useCallback(async () => {
try {
const res = await api.get<GmailStatus>("/gmail/status");
setGmailStatus(res.data);
} catch {
setGmailStatus({ connected: false });
}
}, []);
const loadMicrosoftStatus = useCallback(async () => {
try {
const res = await api.get<MicrosoftGraphStatus>("/microsoft-graph/status");
setMicrosoftStatus(res.data);
} catch {
setMicrosoftStatus({ connected: false });
}
}, []);
const loadImapStatus = useCallback(async () => {
try {
const res = await api.get<ImapStatus>("/imap/status");
setImapStatus(res.data);
} catch {
setImapStatus({ connected: false });
}
}, []);
useEffect(() => {
void loadGmailStatus();
void loadMicrosoftStatus();
void loadImapStatus();
}, [loadGmailStatus, loadMicrosoftStatus, loadImapStatus]);
useEffect(() => {
const onMessage = (event: MessageEvent) => {
const data = event.data as { source?: string; status?: string; message?: string };
if (data?.source === "jobtracker-gmail-oauth") {
if (data.status === "connected") {
toast(data.message || "Gmail connected.", "success");
void loadGmailStatus();
} else {
toast(data.message || "Gmail connection failed.", "error");
}
} else if (data?.source === "jobtracker-microsoft-oauth") {
if (data.status === "connected") {
toast(data.message || "Outlook connected.", "success");
void loadMicrosoftStatus();
} else {
toast(data.message || "Outlook connection failed.", "error");
}
}
};
window.addEventListener("message", onMessage);
return () => window.removeEventListener("message", onMessage);
}, [loadGmailStatus, loadMicrosoftStatus, toast]);
const connectViaPopup = async (connectUrlPath: string, popupName: string, providerLabel: string) => {
try {
const res = await api.get<{ url: string }>(connectUrlPath);
const popup = window.open(res.data.url, popupName, "width=620,height=760,resizable=yes,scrollbars=yes");
if (!popup) toast("Your browser blocked the connect popup. Allow popups and try again.", "error");
} catch (error) {
toast(getApiErrorMessage(error, `Failed to start ${providerLabel} connection.`), "error");
}
};
const disconnect = async (path: string, reload: () => Promise<void>, providerLabel: string) => {
try {
await api.delete(path);
await reload();
toast(`${providerLabel} disconnected.`, "success");
} catch (error) {
toast(getApiErrorMessage(error, `Failed to disconnect ${providerLabel}.`), "error");
}
};
const connectImap = async () => {
if (!imapForm.host.trim() || !imapForm.username.trim() || !imapForm.password) {
toast("Host, username, and password are required.", "error");
return;
}
setImapConnecting(true);
try {
await api.post("/imap/connect", imapForm);
setImapForm((prev) => ({ ...prev, password: "" }));
await loadImapStatus();
toast("IMAP account connected.", "success");
} catch (error) {
toast(getApiErrorMessage(error, "Failed to connect IMAP account."), "error");
} finally {
setImapConnecting(false);
}
};
return (
<Paper sx={{ p: 2 }}>
<Typography sx={{ fontWeight: 950, mb: 0.5 }}>Linked email accounts</Typography>
<Typography sx={{ color: "text.secondary", mb: 2 }}>
Connect a mailbox so recruiter correspondence can be linked to jobs automatically.
</Typography>
<Stack spacing={2}>
<ProviderRow
label="Gmail"
connected={Boolean(gmailStatus?.connected)}
address={gmailStatus?.gmailAddress ?? null}
onConnect={() => void connectViaPopup("/gmail/connect-url", "jobtracker-gmail-connect", "Gmail")}
onDisconnect={() => void disconnect("/gmail/connection", loadGmailStatus, "Gmail")}
/>
<Divider />
<ProviderRow
label="Outlook / Microsoft 365"
connected={Boolean(microsoftStatus?.connected)}
address={microsoftStatus?.mailAddress ?? null}
onConnect={() => void connectViaPopup("/microsoft-graph/connect-url", "jobtracker-microsoft-connect", "Outlook")}
onDisconnect={() => void disconnect("/microsoft-graph/connection", loadMicrosoftStatus, "Outlook")}
/>
<Divider />
<Box>
<ProviderRow
label="Other (IMAP)"
connected={Boolean(imapStatus?.connected)}
address={imapStatus?.username ?? null}
onDisconnect={() => void disconnect("/imap/connection", loadImapStatus, "IMAP")}
/>
{!imapStatus?.connected && (
<Box sx={{ mt: 1.5, display: "grid", gap: 1.25, gridTemplateColumns: { xs: "1fr", sm: "2fr 1fr" }, maxWidth: 520 }}>
<TextField
size="small"
label="IMAP host"
placeholder="imap.example.com"
value={imapForm.host}
onChange={(e) => setImapForm((prev) => ({ ...prev, host: e.target.value }))}
/>
<TextField
size="small"
label="Port"
type="number"
value={imapForm.port}
onChange={(e) => setImapForm((prev) => ({ ...prev, port: Number(e.target.value) || 993 }))}
/>
<TextField
size="small"
label="Username"
value={imapForm.username}
onChange={(e) => setImapForm((prev) => ({ ...prev, username: e.target.value }))}
sx={{ gridColumn: "1 / -1" }}
/>
<TextField
size="small"
label="Password"
type="password"
value={imapForm.password}
onChange={(e) => setImapForm((prev) => ({ ...prev, password: e.target.value }))}
sx={{ gridColumn: "1 / -1" }}
/>
<FormControlLabel
sx={{ gridColumn: "1 / -1" }}
control={<Checkbox checked={imapForm.useSsl} onChange={(e) => setImapForm((prev) => ({ ...prev, useSsl: e.target.checked }))} />}
label="Use SSL/TLS"
/>
<Button
variant="contained"
onClick={connectImap}
disabled={imapConnecting}
sx={{ gridColumn: "1 / -1", justifySelf: "start" }}
>
{imapConnecting ? "Connecting…" : "Connect IMAP account"}
</Button>
</Box>
)}
</Box>
</Stack>
</Paper>
);
}
function ProviderRow({
label,
connected,
address,
onConnect,
onDisconnect,
}: {
label: string;
connected: boolean;
address: string | null;
onConnect?: () => void;
onDisconnect: () => void;
}) {
return (
<Stack direction="row" alignItems="center" justifyContent="space-between" flexWrap="wrap" gap={1}>
<Box>
<Typography sx={{ fontWeight: 700 }}>{label}</Typography>
{connected ? (
<Chip
size="small"
icon={<CheckCircleIcon fontSize="small" />}
color="success"
variant="outlined"
label={address || "Connected"}
sx={{ mt: 0.5 }}
/>
) : (
<Typography variant="caption" sx={{ color: "text.secondary" }}>Not connected</Typography>
)}
</Box>
{connected ? (
<Button size="small" variant="outlined" color="error" onClick={onDisconnect}>Disconnect</Button>
) : (
onConnect && <Button size="small" variant="outlined" onClick={onConnect}>Connect</Button>
)}
</Stack>
);
}
@@ -22,6 +22,7 @@ import { useNavigate } from "react-router-dom";
import { JobTableColumns } from "./JobTable";
import ImportExportJobs from "./ImportExportJobs";
import GoogleAuthCard from "./GoogleAuthCard";
import EmailProviderConnections from "./EmailProviderConnections";
import RulesSettingsCard from "./RulesSettingsCard";
import BackupCard from "./BackupCard";
import QuickCaptureCard from "./QuickCaptureCard";
@@ -338,6 +339,9 @@ export default function SettingsView({
<TabPanel value={tab} index={3}>
<AuthStatusCard />
<GoogleAuthCard />
<Box sx={{ mt: 2 }}>
<EmailProviderConnections />
</Box>
</TabPanel>
<TabPanel value={tab} index={4}>
@@ -0,0 +1,72 @@
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 { ToastProvider } from "./toast";
import { api } from "./api";
import EmailProviderConnections from "./components/EmailProviderConnections";
jest.mock("./api", () => ({
api: {
get: jest.fn(),
post: jest.fn(),
delete: jest.fn(),
interceptors: { request: { use: jest.fn() }, response: { use: jest.fn() } },
},
getApiErrorMessage: (_err: unknown, fallback: string) => fallback,
}));
const mockedApi = api as jest.Mocked<typeof api>;
function renderComponent() {
return render(
<ToastProvider>
<EmailProviderConnections />
</ToastProvider>,
);
}
describe("EmailProviderConnections", () => {
beforeEach(() => {
jest.clearAllMocks();
});
it("renders connected state for Gmail and Outlook, disconnected form for IMAP", async () => {
mockedApi.get.mockImplementation((path: string) => {
if (path === "/gmail/status") return Promise.resolve({ data: { connected: true, gmailAddress: "me@gmail.test" } });
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
return Promise.reject(new Error("unexpected path"));
});
renderComponent();
expect(await screen.findByText("me@gmail.test")).toBeInTheDocument();
expect(screen.getByLabelText("IMAP host")).toBeInTheDocument();
expect(screen.getAllByText("Not connected").length).toBeGreaterThan(0);
});
it("submits IMAP connect form and reloads status on success", async () => {
mockedApi.get.mockImplementation((path: string) => {
if (path === "/gmail/status") return Promise.resolve({ data: { connected: false } });
if (path === "/microsoft-graph/status") return Promise.resolve({ data: { connected: false } });
if (path === "/imap/status") return Promise.resolve({ data: { connected: false } });
return Promise.reject(new Error("unexpected path"));
});
mockedApi.post.mockResolvedValueOnce({ data: { username: "user@example.test" } });
renderComponent();
await screen.findByLabelText("IMAP host");
await userEvent.type(screen.getByLabelText("IMAP host"), "imap.example.test");
await userEvent.type(screen.getByLabelText("Username"), "user@example.test");
await userEvent.type(screen.getByLabelText("Password"), "secret");
await userEvent.click(screen.getByRole("button", { name: /connect imap account/i }));
await waitFor(() => expect(mockedApi.post).toHaveBeenCalledWith("/imap/connect", expect.objectContaining({
host: "imap.example.test",
username: "user@example.test",
password: "secret",
})));
});
});
+12 -6
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 { useNavigate } from "react-router-dom";
import { useLocation, useNavigate } from "react-router-dom";
import DashboardIcon from "@mui/icons-material/SpaceDashboardOutlined";
import AlarmIcon from "@mui/icons-material/NotificationsActiveOutlined";
@@ -42,6 +42,7 @@ 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.
@@ -54,6 +55,11 @@ 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 }}>
@@ -78,7 +84,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={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
<Button variant="contained" onClick={goToLogin} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 700 }}>
Sign in
</Button>
</Stack>
@@ -100,7 +106,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={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25 }}>
<Button size="large" variant="contained" onClick={goToLogin} 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 }}>
@@ -217,7 +223,7 @@ export default function LandingPage() {
<Button
fullWidth
variant={tier.highlight ? "contained" : "outlined"}
onClick={() => navigate("/login")}
onClick={goToLogin}
sx={tier.highlight ? { background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800 } : { fontWeight: 700 }}
>
{tier.cta}
@@ -237,7 +243,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={() => navigate("/login")} sx={{ background: "linear-gradient(90deg,#6366f1,#22d3ee)", color: "#0b1020", fontWeight: 800, px: 4, py: 1.25, whiteSpace: "nowrap" }}>
<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" }}>
Sign in
</Button>
</Box>
@@ -248,7 +254,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={() => navigate("/login")} sx={{ fontWeight: 700 }}>Sign in</Button>
<Button variant="text" onClick={goToLogin} sx={{ fontWeight: 700 }}>Sign in</Button>
</Stack>
</Container>
</Box>
+29
View File
@@ -384,6 +384,35 @@ export interface GmailStatus {
lastSyncError?: string | null;
}
export interface MicrosoftGraphStatus {
connected: boolean;
mailAddress?: string | null;
connectedAt?: string;
lastSyncedAt?: string;
lastSyncAttemptedAt?: string;
lastSyncSucceededAt?: string;
lastSyncMode?: string | null;
lastSyncSource?: string | null;
lastSyncStatus?: string | null;
lastSyncError?: string | null;
}
export interface ImapStatus {
connected: boolean;
host?: string | null;
port?: number | null;
useSsl?: boolean | null;
username?: string | null;
connectedAt?: string;
lastSyncedAt?: string;
lastSyncAttemptedAt?: string;
lastSyncSucceededAt?: string;
lastSyncMode?: string | null;
lastSyncSource?: string | null;
lastSyncStatus?: string | null;
lastSyncError?: string | null;
}
export interface GmailManualSyncResult {
queriesRun: number;
candidateThreadCount: number;