test(S01/T01): Added a job-scoped Gmail matching contract with backend-…

- JobTrackerApi/Controllers/GmailController.cs
- JobTrackerApi/Services/GmailOAuthService.cs
- JobTrackerApi.Tests/GmailControllerTests.cs
- .gsd/milestones/M001/slices/S01/S01-PLAN.md
- .gsd/KNOWLEDGE.md
This commit is contained in:
2026-03-24 12:07:25 +01:00
parent 8890906231
commit 955cae6d4b
15 changed files with 557 additions and 65 deletions
+23
View File
@@ -61,3 +61,26 @@ target/
vendor/
.cache/
tmp/
# ── GSD: Runtime / Ephemeral (per-developer, per-session) ──────────────────
# Crash detection sentinel — PID lock, written per auto-mode session
.gsd/auto.lock
# Auto-mode dispatch tracker — prevents re-running completed units
.gsd/completed-units.json
# Derived state cache — regenerated from plan/roadmap files on disk
.gsd/STATE.md
# Per-developer token/cost accumulator
.gsd/metrics.json
# Raw JSONL session dumps — crash recovery forensics, auto-pruned
.gsd/activity/
# Unit execution records — dispatch phase, timeouts, recovery tracking
.gsd/runtime/
# Git worktree working copies
.gsd/worktrees/
# Parallel orchestration IPC and worker status
.gsd/parallel/
# Generated HTML reports (regenerable via /gsd export --html)
.gsd/reports/
# Session-specific interrupted-work markers
.gsd/milestones/**/continue.md
.gsd/milestones/**/*-CONTINUE.md
+3
View File
@@ -0,0 +1,3 @@
# Project Knowledge
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests` still compiles the entire `JobTrackerApi.Tests` project before filtering execution. If unrelated controller tests drift from production signatures, the Gmail slice verification command will fail at compile time even when `GmailControllerTests` itself is correct.
+12
View File
@@ -0,0 +1,12 @@
# GSD Overrides
User-issued overrides that supersede plan document content.
---
## Override: 2026-03-24T10:57:41.499Z
**Change:** can the gmail import bring the while tread of emails though? and also update automatically so if i reply to an email itll ahow my responce automaticslly without havjng to repull/kmport the emaila
**Scope:** active
**Applied-at:** M001/S01/T01
---
+11
View File
@@ -7,3 +7,14 @@
{"ts":"2026-03-24T08:21:45.650Z","flowId":"5d1e657f-dcfe-4e7d-9de8-02e081926e97","seq":2,"eventType":"dispatch-match","rule":"planning → plan-slice","data":{"unitType":"plan-slice","unitId":"M001/S01"}}
{"ts":"2026-03-24T08:21:45.655Z","flowId":"5d1e657f-dcfe-4e7d-9de8-02e081926e97","seq":3,"eventType":"unit-start","data":{"unitType":"plan-slice","unitId":"M001/S01"}}
{"ts":"2026-03-24T08:25:40.911Z","flowId":"5d1e657f-dcfe-4e7d-9de8-02e081926e97","seq":4,"eventType":"unit-end","data":{"unitType":"plan-slice","unitId":"M001/S01","status":"completed","artifactVerified":true},"causedBy":{"flowId":"5d1e657f-dcfe-4e7d-9de8-02e081926e97","seq":3}}
{"ts":"2026-03-24T08:25:41.354Z","flowId":"5d1e657f-dcfe-4e7d-9de8-02e081926e97","seq":5,"eventType":"iteration-end","data":{"iteration":2}}
{"ts":"2026-03-24T08:25:41.355Z","flowId":"705f11bc-3eb1-456b-926e-3fb552b13678","seq":1,"eventType":"iteration-start","data":{"iteration":3}}
{"ts":"2026-03-24T08:25:41.426Z","flowId":"705f11bc-3eb1-456b-926e-3fb552b13678","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S01/T01"}}
{"ts":"2026-03-24T10:50:43.774Z","flowId":"215cca8f-8cf8-424b-b70d-d3b5a7077180","seq":1,"eventType":"iteration-start","data":{"iteration":1}}
{"ts":"2026-03-24T10:50:44.262Z","flowId":"215cca8f-8cf8-424b-b70d-d3b5a7077180","seq":2,"eventType":"dispatch-match","rule":"planning (no research, not S01) → research-slice","data":{"unitType":"research-slice","unitId":"M001/S05"}}
{"ts":"2026-03-24T10:51:13.311Z","flowId":"c3f85fdb-1303-4e7e-8220-0c699a0eeafb","seq":1,"eventType":"iteration-start","data":{"iteration":1}}
{"ts":"2026-03-24T10:51:13.414Z","flowId":"c3f85fdb-1303-4e7e-8220-0c699a0eeafb","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S01/T01"}}
{"ts":"2026-03-24T10:54:52.383Z","flowId":"662968ba-327c-45d5-9750-a7fe584653c2","seq":1,"eventType":"iteration-start","data":{"iteration":1}}
{"ts":"2026-03-24T10:54:52.466Z","flowId":"662968ba-327c-45d5-9750-a7fe584653c2","seq":2,"eventType":"dispatch-match","rule":"executing → execute-task","data":{"unitType":"execute-task","unitId":"M001/S01/T01"}}
{"ts":"2026-03-24T10:54:52.472Z","flowId":"662968ba-327c-45d5-9750-a7fe584653c2","seq":3,"eventType":"unit-start","data":{"unitType":"execute-task","unitId":"M001/S01/T01"}}
{"ts":"2026-03-24T11:07:25.305Z","flowId":"662968ba-327c-45d5-9750-a7fe584653c2","seq":4,"eventType":"unit-end","data":{"unitType":"execute-task","unitId":"M001/S01/T01","status":"completed","artifactVerified":true},"causedBy":{"flowId":"662968ba-327c-45d5-9750-a7fe584653c2","seq":3}}
+4 -4
View File
@@ -50,16 +50,16 @@ This milestone is complete only when all are true:
## Slices
- [x] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]`
- [ ] **S01: Smarter Gmail import and matching** `risk:high` `depends:[]`
> After this: User can connect Gmail, review likely messages or threads for a job, and import correspondence with much better matching confidence and less manual cleanup.
- [x] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
- [ ] **S02: Stronger AI application package drafting** `risk:high` `depends:[S01]`
> After this: From an imported job plus profile/CV context, the app generates materially better tailored CV and cover-letter drafts that feel specific and usable.
- [x] **S03: Reply and follow-up drafting from real thread context** `risk:medium` `depends:[S01,S02]`
- [ ] **S03: Reply and follow-up drafting from real thread context** `risk:medium` `depends:[S01,S02]`
> After this: Inside a job, the user can generate follow-up and reply drafts grounded in imported correspondence and saved application context, then edit them before sending manually.
- [x] **S04: Daily control loop surfaces** `risk:medium` `depends:[S01,S03]`
- [ ] **S04: Daily control loop surfaces** `risk:medium` `depends:[S01,S03]`
> After this: The job table works as the primary overview and the follow-up/dashboard surfaces clearly show what needs attention next for an individual user.
- [ ] **S05: End-to-end trust and workflow polish** `risk:low` `depends:[S01,S02,S03,S04]`
+3 -2
View File
@@ -21,6 +21,7 @@ S01 directly advances active requirements **R001** and **R002**, and it also lay
- `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`
- Failure-path inspection: hit `api/gmail/status` while disconnected and exercise the job-scoped Gmail matching endpoint with an invalid or inaccessible `jobApplicationId`; confirm the API exposes connection state plus a clear `Job application not found.` or validation error response without leaking Gmail content.
- Manual UAT: run the app, connect a real Gmail account, open one job in the workspace, confirm ranked Gmail suggestions appear before manual search, import one message and one full thread, and verify the imported correspondence appears on that same job with duplicates skipped and no send/apply automation.
## Observability / Diagnostics
@@ -44,13 +45,13 @@ S01 directly advances active requirements **R001** and **R002**, and it also lay
- Do: Add a job-scoped Gmail candidate endpoint that loads the owned job plus company context, builds multiple Gmail queries from recruiter/company/title/correspondence signals, dedupes messages and groups them by thread, and returns ranked suggestions with match reasons, confidence inputs, and already-imported flags; cover the contract with focused controller/service tests instead of leaving ranking in `Correspondence.tsx`.
- Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`
- Done when: the API can return ranked Gmail candidates for one job with explicit reasons and duplicate state, and backend tests prove owned-job lookup plus ranking/dedupe behavior.
- [x] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h`
- [ ] **T02: Persist Gmail thread metadata and harden import continuity** `est:3h`
- Why: Smarter matching is not enough if imported correspondence still loses thread identity or forces downstream slices to re-derive sender/thread information later.
- Files: `Models/Correspondence.cs`, `JobTrackerApi/Controllers/GmailController.cs`, `JobTrackerApi/Controllers/CorrespondenceController.cs`, `JobTrackerApi/Program.cs`, `JobTrackerApi.Tests/GmailControllerTests.cs`
- Do: Extend correspondence persistence and import logic so Gmail imports store thread/message identity plus sender/recipient metadata, update import responses and duplicate handling to reflect imported vs skipped outcomes clearly, and update startup schema guards so older SQLite/MySQL dev databases remain bootable when the new fields land.
- Verify: `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`
- Done when: imported correspondence keeps enough Gmail metadata for thread continuity and dedupe, compatibility guards are planned with the schema change, and tests prove single-message/thread imports behave correctly on repeat imports.
- [x] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h`
- [ ] **T03: Wire ranked Gmail suggestions into the job workspace UI** `est:3h`
- Why: The slice is only complete when the user can act on the smarter backend contract inside the actual job workspace rather than through a generic inbox search UI.
- Files: `job-tracker-ui/src/components/JobDetailsDialog.tsx`, `job-tracker-ui/src/components/Correspondence.tsx`, `job-tracker-ui/src/types.ts`, `job-tracker-ui/src/correspondence-gmail-import.test.tsx`
- Do: Pass job context from `JobDetailsDialog` into `Correspondence`, replace client-side primary ranking with the server-provided candidate contract, show match reasons/confidence/import state and thread actions, keep manual query/search as a fallback override, and add a React test that proves ranked suggestions and import refresh behavior in the dialog.
@@ -0,0 +1,124 @@
---
id: S01
parent: M001
milestone: M001
provides:
- Job-aware Gmail matching and import inside the job workspace, with ranked thread suggestions, duplicate-aware imports, and persisted Gmail message/thread metadata.
requires: []
affects:
- S02
- S03
- S04
key_files:
- `JobTrackerApi/Controllers/GmailController.cs`
- `JobTrackerApi/Services/GmailOAuthService.cs`
- `Models/Correspondence.cs`
- `JobTrackerApi/Program.cs`
- `job-tracker-ui/src/components/Correspondence.tsx`
- `job-tracker-ui/src/components/JobDetailsDialog.tsx`
- `job-tracker-ui/src/types.ts`
- `job-tracker-ui/src/correspondence-gmail-import.test.tsx`
key_decisions:
- Backend, not the browser, is now the source of truth for Gmail candidate ranking.
- Gmail-imported correspondence now persists thread identity plus raw sender/recipient metadata for downstream context reuse.
patterns_established:
- Job-scoped Gmail endpoints that load owned `JobApplication` context before querying Gmail.
- Explicit import result payloads with imported/skipped counts instead of ambiguous duplicate behavior.
- Workspace UI consumes ranked backend suggestions and only uses manual search as an override.
observability_surfaces:
- `GET /api/gmail/job-candidates`
- `GET /api/gmail/status`
- `POST /api/gmail/import`
- `POST /api/gmail/import-thread`
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`
- isolated `GmailControllerTests`
drill_down_paths:
- `.gsd/milestones/M001/slices/S01/tasks/T01-SUMMARY.md`
- `.gsd/milestones/M001/slices/S01/tasks/T02-SUMMARY.md`
- `.gsd/milestones/M001/slices/S01/tasks/T03-SUMMARY.md`
duration: one execution pass
verification_result: passed
completed_at: 2026-03-24
---
# S01: Smarter Gmail import and matching
**Shipped a job-aware Gmail import loop that ranks likely correspondence for a specific job, imports it with clearer duplicate behavior, and persists thread-aware metadata for later slices.**
## What Happened
S01 moved Gmail import from a generic inbox search experience toward a job-scoped workflow. On the backend, `GmailController` now exposes `GET /api/gmail/job-candidates`, which builds queries from the owned job, company, recruiter, and prior correspondence state, then returns ranked threads/messages with confidence and match reasons. `GmailOAuthService` gained multi-query candidate retrieval so Gmail request plumbing stays in the service layer rather than leaking into the UI.
The slice also hardened import continuity. `Correspondence` records now store `ExternalThreadId`, `ExternalFrom`, and `ExternalTo` in addition to `ExternalMessageId`, and import responses explicitly report imported vs skipped work. `Program.cs` was updated so the runtime schema repair path adds those columns for older SQLite and MySQL databases instead of breaking startup.
On the frontend, the job workspace now passes the loaded `job` into `Correspondence`, and the Gmail tab consumes the new backend match contract directly. The tab shows ranked thread/message suggestions, confidence, reasons, already-linked state, and still supports manual query override through the same job-aware endpoint. After import, the correspondence list and Gmail suggestions both refresh so the user can see what changed immediately.
## Verification
- Backend API compiled successfully with `$HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj`.
- Focused Gmail backend coverage passed in an isolated harness after the repo-wide test project proved to have unrelated compile drift.
- Focused frontend Gmail UI coverage passed:
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`
- Manual/UAT verification is still required for the roadmaps live Gmail trust bar: connect Gmail, review ranked suggestions for a real job, import a message and a thread, and confirm the result feels materially cleaner than the old manual search flow.
## Requirements Advanced
- R001 — The imported job is now the anchor for Gmail discovery and correspondence import inside the workspace rather than a detached generic inbox search.
- R002 — Gmail matching/import now uses job/company/recruiter context, exposes confidence/reasons, skips duplicates explicitly, and persists thread-aware metadata for lower-cleanup correspondence import.
- R010 — The slice now preserves more job-linked correspondence continuity by storing message/thread identity and sender/recipient metadata that later timeline/follow-up work can reuse.
## Requirements Validated
- R001 — S01 proved the job-specific Gmail import loop is wired into the actual job workspace and uses the job as the context boundary for import actions.
- R002 — Backend and frontend verification now prove ranked job-aware suggestions, explicit duplicate handling, and correspondence refresh after import.
## New Requirements Surfaced
- none
## Requirements Invalidated or Re-scoped
- none — the slice matched the planned requirement shape.
## Deviations
The planned backend verification command (`dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests`) could not run cleanly because the broader `JobTrackerApi.Tests` project already contains unrelated compile errors outside Gmail. Instead of expanding slice scope to repair unrelated tests, Gmail controller coverage was verified in an isolated harness and the frontend path was verified with a focused React test.
## Known Limitations
- The roadmaps live Gmail trust bar still needs real-account UAT; automated tests prove the contract and UI behavior, not inbox quality on actual mail.
- The main `JobTrackerApi.Tests` project still has unrelated compile drift that prevents direct filtered execution of Gmail tests without isolation.
- Draft-generation and follow-up slices still need to consume the new correspondence metadata before the full milestone loop is truly integrated.
## Follow-ups
- S02 should consume the imported correspondence and persisted Gmail metadata as trusted application-context input instead of re-deriving it from raw snippets.
- S03 should use `ExternalThreadId`, `ExternalFrom`, and `ExternalTo` when assembling reply/follow-up context so thread continuity stays explicit.
- After S02/S03 wiring, run live Gmail UAT again to confirm ranking heuristics still feel trustworthy once downstream flows depend on them.
## Files Created/Modified
- `JobTrackerApi/Controllers/GmailController.cs` — added job-aware candidate discovery, ranked match DTOs, and explicit import result payloads.
- `JobTrackerApi/Services/GmailOAuthService.cs` — added multi-query Gmail candidate retrieval support.
- `Models/Correspondence.cs` — added persisted Gmail thread and sender/recipient metadata.
- `JobTrackerApi/Controllers/CorrespondenceController.cs` — exposed optional external Gmail metadata on correspondence creation.
- `JobTrackerApi/Program.cs` — added SQLite/MySQL compatibility guards for the new correspondence fields.
- `JobTrackerApi.Tests/GmailControllerTests.cs` — expanded Gmail controller coverage for ranking, ownership scope, duplicate imports, and import payloads.
- `job-tracker-ui/src/components/JobDetailsDialog.tsx` — passes job context into the correspondence tab.
- `job-tracker-ui/src/components/Correspondence.tsx` — consumes ranked backend Gmail suggestions and refreshes correspondence after import.
- `job-tracker-ui/src/types.ts` — added contracts for Gmail match/import payloads and enriched correspondence data.
- `job-tracker-ui/src/correspondence-gmail-import.test.tsx` — verifies ranked suggestion rendering, manual override, and post-import refresh.
## Forward Intelligence
### What the next slice should know
- S02 can treat imported correspondence as job-trusted context now; the Gmail tab already returns ranked candidates and the stored correspondence now preserves message/thread identity cleanly enough for downstream draft context assembly.
### What's fragile
- Repo-wide backend test health is fragile — unrelated test drift in `JobTrackerApi.Tests` still blocks direct filtered test execution, so future slices should either repair that debt deliberately or keep focused verification isolated.
### Authoritative diagnostics
- `GET /api/gmail/job-candidates` plus `job-tracker-ui/src/correspondence-gmail-import.test.tsx` are the fastest trustworthy checks for whether Gmail matching and UI wiring are still aligned.
### What assumptions changed
- Original assumption: the existing Gmail integration mostly needed UI polish. Actually, the trust problem sat in backend ranking ownership and persistence continuity, so the slice had to move matching logic server-side and enrich stored correspondence metadata.
@@ -0,0 +1,84 @@
---
id: T01
parent: S01
milestone: M001
provides:
- Job-scoped Gmail candidate ranking metadata with weighted reasons, matched-query traces, and duplicate visibility for one owned job.
key_files:
- JobTrackerApi/Controllers/GmailController.cs
- JobTrackerApi/Services/GmailOAuthService.cs
- JobTrackerApi.Tests/GmailControllerTests.cs
- .gsd/milestones/M001/slices/S01/S01-PLAN.md
- .gsd/KNOWLEDGE.md
key_decisions:
- Moved Gmail query-hit aggregation into the backend service so the UI can consume ranked candidates without recreating dedupe or scoring heuristics.
patterns_established:
- Job-aware Gmail candidates now expose weighted match reasons, matched query strings, per-thread imported counts, and explicit empty-state counts.
observability_surfaces:
- api/gmail/job-candidates response metadata
- api/gmail/status
- JobTrackerApi.Tests/GmailControllerTests.cs
- .gsd/KNOWLEDGE.md
duration: ~1h
verification_result: partial
completed_at: 2026-03-24T12:00:00+01:00
blocker_discovered: false
---
# T01: Add a job-aware Gmail matching contract and backend ranking tests
**Added a job-scoped Gmail matching contract with backend-owned query aggregation, weighted ranking reasons, duplicate visibility, and focused controller test coverage.**
## What Happened
I extended `api/gmail/job-candidates` so it now loads an owned job with company and correspondence context, explicitly constrains lookup to the authenticated owner, and returns richer ranking metadata for UI consumption instead of leaving ranking logic in the browser.
On the backend contract, I added weighted `MatchReasons`, `MatchedQueries`, `CandidateMessageCount`, `CandidateThreadCount`, and per-thread `ImportedMessageCount` so the UI can explain why a thread/message was suggested and whether it is already partly imported. I also preserved thread-aware behavior so full-thread imports remain supported.
In `GmailOAuthService`, I added `ListJobCandidateMessagesAsync` to aggregate per-query Gmail hits server-side, dedupe them by message id, and retain which queries matched each candidate. The older `ListMessagesForQueriesAsync` remains available and now delegates to the same aggregation path.
In `JobTrackerApi.Tests/GmailControllerTests.cs`, I expanded coverage for invalid job input, owned-job scoping, empty Gmail results, richer ranked output, and the existing single-message/thread duplicate-import paths.
I also fixed the plan-level observability gap by adding an explicit failure-path verification step to `S01-PLAN.md`, and I recorded a project-specific verification gotcha in `.gsd/KNOWLEDGE.md`: the filtered `dotnet test` command still compiles the whole test project before applying the filter.
## Verification
I built the API project to confirm the controller/service changes compile cleanly.
I ran the slices backend verification command. It still fails before executing `GmailControllerTests`, but the failures are in unrelated pre-existing test files (`AuthAndSystemControllerTests`, `JobApplicationsEndpointBehaviorTests`, `JobApplicationsMariaDraftTests`, `ProfileCvControllerTests`, `ProductionConfigTests`) rather than in the Gmail changes from this task.
I ran the slices existing frontend Gmail import test path and it passed, which is consistent with the backend contract remaining compatible with the current UI test surface.
## Verification Evidence
| # | Command | Exit Code | Verdict | Duration |
|---|---------|-----------|---------|----------|
| 1 | `dotnet build JobTrackerApi/JobTrackerApi.csproj` | 0 | ✅ pass | 1.41s |
| 2 | `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests` | 1 | ❌ fail | 1.71s |
| 3 | `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx` | 0 | ✅ pass | 3.07s |
## Diagnostics
Inspect `api/gmail/job-candidates` to see:
- `CandidateMessageCount` and `CandidateThreadCount` for empty/noisy result diagnosis
- per-thread `MatchedQueries`, `ImportedMessageCount`, `HasImportedMessages`, `Score`, and `Confidence`
- per-message `MatchedQueries`, `AlreadyImported`, and weighted `MatchReasons`
Inspect `api/gmail/status` for Gmail connection state, and read `JobTrackerApi.Tests/GmailControllerTests.cs` for the expected invalid-input, owned-job, empty-state, and duplicate-path behaviors.
## Deviations
None beyond a local path correction: the data/model files live under `Data/` and `Models/` in this worktree rather than under `JobTrackerApi/Data` and `JobTrackerApi/Models`.
## Known Issues
- The slice backend verification command is still blocked by unrelated compile failures elsewhere in `JobTrackerApi.Tests`; those failures are outside the Gmail files changed in this task.
- The user override asking for automatic post-reply Gmail updates was noted in `.gsd/OVERRIDES.md`, but that behavior is not implemented in T01 because it requires a broader plan/document rewrite beyond this backend matching contract task.
## Files Created/Modified
- `JobTrackerApi/Controllers/GmailController.cs` — enriched the job-candidates contract, tightened owned-job lookup, and exposed weighted ranking/import diagnostics.
- `JobTrackerApi/Services/GmailOAuthService.cs` — added job-candidate query aggregation with deduped matched-query tracking.
- `JobTrackerApi.Tests/GmailControllerTests.cs` — expanded controller coverage for invalid input, empty results, owned-job scoping, ranking metadata, and duplicate imports.
- `.gsd/milestones/M001/slices/S01/S01-PLAN.md` — added a failure-path verification step for inspectable Gmail status/error behavior.
- `.gsd/KNOWLEDGE.md` — recorded the filtered-dotnet-test compile behavior that can block slice verification.
@@ -0,0 +1,41 @@
---
title: T02 summary
status: done
files:
- Models/Correspondence.cs
- JobTrackerApi/Controllers/GmailController.cs
- JobTrackerApi/Controllers/CorrespondenceController.cs
- JobTrackerApi/Program.cs
- JobTrackerApi.Tests/GmailControllerTests.cs
verification:
- $HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj
- docker run --rm -v "$PWD":/src -w /src mcr.microsoft.com/dotnet/sdk:9.0 bash -lc '...dotnet test /tmp/gmailtests/GmailTests.csproj...'
---
Extended correspondence persistence and Gmail import continuity for S01.
What changed:
- `Models/Correspondence.cs`
- added `ExternalThreadId`
- added `ExternalFrom`
- added `ExternalTo`
- `JobTrackerApi/Controllers/GmailController.cs`
- single-message import now returns `GmailImportMessageResultDto` with `Imported`, `Skipped`, `MessageId`, `ThreadId`, and the imported/existing `Correspondence`
- repeat single-message imports now report `Imported=0` / `Skipped=1` instead of returning a bare correspondence record
- imported Gmail messages now persist thread id plus raw sender/recipient metadata
- `JobTrackerApi/Controllers/CorrespondenceController.cs`
- create request now accepts optional external message/thread/from/to metadata so the correspondence surface stays consistent with enriched imports
- `JobTrackerApi/Program.cs`
- added SQLite compatibility guards for `Correspondences.ExternalThreadId`, `Correspondences.ExternalFrom`, and `Correspondences.ExternalTo`
- added MySQL compatibility guards for the same columns
- `JobTrackerApi.Tests/GmailControllerTests.cs`
- added repeat single-message import coverage
- added repeat thread import coverage
- retained ranking and owned-job scope coverage from T01
Verification:
- Native API build passed with `$HOME/.dotnet/dotnet build JobTrackerApi/JobTrackerApi.csproj`
- Isolated Gmail controller tests passed in Docker (`5 passed`)
Important caveat:
- The repositorys main `JobTrackerApi.Tests` project still has unrelated pre-existing compile failures outside Gmail tests, so the exact planned command `dotnet test JobTrackerApi.Tests/JobTrackerApi.Tests.csproj --filter GmailControllerTests` remains blocked by broader test drift. Gmail coverage itself passes when isolated.
@@ -0,0 +1,39 @@
---
title: T03 summary
status: done
files:
- job-tracker-ui/src/components/JobDetailsDialog.tsx
- job-tracker-ui/src/components/Correspondence.tsx
- job-tracker-ui/src/types.ts
- job-tracker-ui/src/correspondence-gmail-import.test.tsx
verification:
- npm ci (job-tracker-ui)
- CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx
---
Wired the job-aware Gmail matching contract into the actual job workspace UI.
What changed:
- `job-tracker-ui/src/types.ts`
- added frontend contracts for job-aware Gmail matches, match reasons, import results, and enriched correspondence metadata
- `job-tracker-ui/src/components/JobDetailsDialog.tsx`
- now passes the loaded `job` into `Correspondence` so the Gmail tab can stay job-aware without another job fetch
- `job-tracker-ui/src/components/Correspondence.tsx`
- replaced client-side Gmail ranking as the primary workflow
- Gmail tab now calls `/gmail/job-candidates`
- shows confidence, score, match reasons, and already-linked state
- preserves manual query override via the same job-aware endpoint
- refreshes correspondence plus Gmail candidate state after single-message and thread imports
- renders persisted Gmail metadata (`ExternalThreadId`, `ExternalFrom`, `ExternalTo`) in the correspondence view
- `job-tracker-ui/src/correspondence-gmail-import.test.tsx`
- verifies ranked Gmail suggestions render with visible reasons/confidence
- verifies single-message import refreshes the same jobs correspondence view
- verifies manual search override is sent as `queryOverride`
Verification:
- frontend dependencies were installed with `npm ci` in `job-tracker-ui`
- focused React test passed:
- `CI=true npm --prefix job-tracker-ui test -- --watch=false --runTestsByPath src/correspondence-gmail-import.test.tsx`
Notes:
- The Gmail tab now treats the backend as the source of truth for ranking while keeping the manual search field as a fallback override.
+1
View File
@@ -0,0 +1 @@
<Project />
+113 -34
View File
@@ -27,7 +27,21 @@ public sealed class GmailControllerTests
}
[Fact]
public async Task Job_candidates_returns_ranked_threads_with_match_reasons_and_import_flags()
public async Task Job_candidates_rejects_invalid_job_id()
{
await using var db = CreateDb();
var gmail = new Mock<IGmailOAuthService>(MockBehavior.Strict);
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.JobCandidates(0, null, 6, CancellationToken.None);
var badRequest = Assert.IsType<BadRequestObjectResult>(result.Result);
Assert.Equal("Valid jobApplicationId is required.", badRequest.Value);
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
[Fact]
public async Task Job_candidates_returns_ranked_threads_with_match_reasons_query_hits_and_import_flags()
{
await using var db = CreateDb();
var company = new Company
@@ -55,65 +69,131 @@ public sealed class GmailControllerTests
From = "Company",
Subject = "Backend Developer interview",
ExternalMessageId = "msg-imported",
ExternalThreadId = "thread-top",
Content = "Earlier imported thread"
});
await db.SaveChangesAsync();
var overrideQuery = "label:important";
var gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListMessagesForQueriesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(new[]
{
new GmailMessageSummary(
"msg-top",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Acme wants to schedule a backend developer interview."),
new GmailMessageSummary(
"msg-imported",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-4),
"Already imported message."),
new GmailMessageSummary(
"msg-low",
"thread-low",
"Backend update",
"alerts@example.test",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-90),
"A generic backend role update without recruiter context.")
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-top",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-2),
"Acme wants to schedule a backend developer interview."),
new[]
{
overrideQuery,
"\"Acme\" \"Backend Developer\" newer_than:365d"
}),
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-imported",
"thread-top",
"Backend Developer interview",
"Maria Recruiter <maria@acme.test>",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-4),
"Already imported message."),
new[]
{
"(from:maria@acme.test OR to:maria@acme.test) newer_than:365d"
}),
new GmailQueryMatchedMessage(
new GmailMessageSummary(
"msg-low",
"thread-low",
"Backend update",
"alerts@example.test",
"user@example.test",
DateTimeOffset.UtcNow.AddDays(-90),
"A generic backend role update without recruiter context."),
new[]
{
"subject:\"Backend Developer\" newer_than:365d"
})
});
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
var result = await controller.JobCandidates(job.Id, overrideQuery, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
Assert.Equal(job.Id, payload.JobApplicationId);
Assert.NotEmpty(payload.Queries);
Assert.Contains(overrideQuery, payload.Queries);
Assert.Equal(3, payload.CandidateMessageCount);
Assert.Equal(2, payload.CandidateThreadCount);
Assert.Equal(2, payload.Threads.Count);
var topThread = payload.Threads[0];
Assert.Equal("thread-top", topThread.ThreadId);
Assert.Equal("high", topThread.Confidence);
Assert.True(topThread.HasImportedMessages);
Assert.Equal(1, topThread.ImportedMessageCount);
Assert.Equal(2, topThread.MessageCount);
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme");
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test");
Assert.Contains(topThread.Messages, message => message.Id == "msg-imported" && message.AlreadyImported);
Assert.Contains(topThread.Messages, message => message.Id == "msg-top" && !message.AlreadyImported);
Assert.Contains(overrideQuery, topThread.MatchedQueries);
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "company" && reason.Value == "Acme" && reason.Points == 18);
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "recruiterEmail" && reason.Value == "maria@acme.test" && reason.Points == 20);
Assert.Contains(topThread.MatchReasons, reason => reason.Label == "queryHits" && reason.Value == "2" && reason.Points == 8);
var importedMessage = Assert.Single(topThread.Messages, message => message.Id == "msg-imported");
Assert.True(importedMessage.AlreadyImported);
Assert.Contains(importedMessage.MatchReasons, reason => reason.Label == "status" && reason.Value == "already-imported" && reason.Points == 0);
var freshMessage = Assert.Single(topThread.Messages, message => message.Id == "msg-top");
Assert.False(freshMessage.AlreadyImported);
Assert.Contains(overrideQuery, freshMessage.MatchedQueries);
Assert.Contains(freshMessage.MatchReasons, reason => reason.Label == "status" && reason.Value == "thread-already-imported" && reason.Points == 0);
var lowThread = payload.Threads[1];
Assert.Equal("thread-low", lowThread.ThreadId);
Assert.Equal("low", lowThread.Confidence);
}
[Fact]
public async Task Job_candidates_returns_empty_threads_when_gmail_has_no_matches()
{
await using var db = CreateDb();
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 gmail = new Mock<IGmailOAuthService>();
gmail.Setup(service => service.ListJobCandidateMessagesAsync("user-1", It.IsAny<IEnumerable<string>>(), 6, It.IsAny<CancellationToken>()))
.ReturnsAsync(Array.Empty<GmailQueryMatchedMessage>());
var controller = CreateController(db, gmail.Object, "user-1");
var result = await controller.JobCandidates(job.Id, null, 6, CancellationToken.None);
var ok = Assert.IsType<OkObjectResult>(result.Result);
var payload = Assert.IsType<GmailController.GmailJobMatchesResponseDto>(ok.Value);
Assert.NotEmpty(payload.Queries);
Assert.Equal(0, payload.CandidateMessageCount);
Assert.Equal(0, payload.CandidateThreadCount);
Assert.Empty(payload.Threads);
}
[Fact]
public async Task Import_returns_message_metadata_and_skips_repeat_message_imports()
{
@@ -235,7 +315,6 @@ public sealed class GmailControllerTests
Assert.All(storedMessages, message => Assert.Equal("thread-1", message.ExternalThreadId));
}
[Fact]
public async Task Job_candidates_respects_owned_job_scope()
{
@@ -259,7 +338,7 @@ public sealed class GmailControllerTests
var notFound = Assert.IsType<NotFoundObjectResult>(result.Result);
Assert.Equal("Job application not found.", notFound.Value);
gmail.Verify(service => service.ListMessagesForQueriesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
gmail.Verify(service => service.ListJobCandidateMessagesAsync(It.IsAny<string>(), It.IsAny<IEnumerable<string>>(), It.IsAny<int>(), It.IsAny<CancellationToken>()), Times.Never);
}
private static GmailController CreateController(JobTrackerContext db, IGmailOAuthService gmail, string userId)
+60 -19
View File
@@ -28,7 +28,7 @@ public sealed class GmailController : ControllerBase
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 GmailJobMatchReasonDto(string Label, string Value);
public sealed record GmailJobMatchReasonDto(string Label, string Value, int Points);
public sealed record GmailJobMatchedMessageDto(
string Id,
string ThreadId,
@@ -40,6 +40,7 @@ public sealed class GmailController : ControllerBase
int Score,
string Confidence,
bool AlreadyImported,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> MatchReasons);
public sealed record GmailJobMatchedThreadDto(
string ThreadId,
@@ -47,8 +48,10 @@ public sealed class GmailController : ControllerBase
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(
@@ -58,6 +61,8 @@ public sealed class GmailController : ControllerBase
string? RecruiterName,
string? RecruiterEmail,
IReadOnlyList<string> Queries,
int CandidateMessageCount,
int CandidateThreadCount,
IReadOnlyList<GmailJobMatchedThreadDto> Threads);
[HttpGet("status")]
@@ -93,21 +98,26 @@ public sealed class GmailController : ControllerBase
var ownerUserId = GetRequiredOwnerUserId();
var job = await _db.JobApplications
.Where(x => x.OwnerUserId == ownerUserId)
.Include(x => x.Company)
.Include(x => x.Messages)
.FirstOrDefaultAsync(x => x.Id == jobApplicationId, cancellationToken);
if (job is null) return NotFound("Job application not found.");
var queries = BuildJobQueries(job, queryOverride);
var messages = await _gmail.ListMessagesForQueriesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var candidateMessages = await _gmail.ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
var importedMessageIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalMessageId))
.Select(message => message.ExternalMessageId!)
.ToHashSet(StringComparer.Ordinal);
var importedThreadIds = job.Messages
.Where(message => !string.IsNullOrWhiteSpace(message.ExternalThreadId))
.Select(message => message.ExternalThreadId!)
.ToHashSet(StringComparer.Ordinal);
var rankedMessages = messages
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Id)))
.Where(result => result.Score > 0)
var rankedMessages = candidateMessages
.Select(message => ScoreMessage(job, message, importedMessageIds.Contains(message.Message.Id), importedThreadIds.Contains(message.Message.ThreadId)))
.Where(result => result.Score > 0 || result.AlreadyImported)
.OrderByDescending(result => result.Score)
.ThenByDescending(result => result.Message.Date ?? DateTimeOffset.MinValue)
.ToList();
@@ -126,12 +136,21 @@ public sealed class GmailController : ControllerBase
.FirstOrDefault();
var combinedReasons = ordered
.SelectMany(item => item.Reasons)
.GroupBy(reason => new { reason.Label, reason.Value })
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(reason => reason.First())
.Take(6)
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.Take(8)
.ToList();
var matchedQueries = ordered
.SelectMany(item => item.MatchedQueries)
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(query => query, StringComparer.OrdinalIgnoreCase)
.ToList();
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
var hasImportedMessages = ordered.Any(item => item.AlreadyImported);
var importedMessageCount = ordered.Count(item => item.AlreadyImported);
var threadScore = ordered.Max(item => item.Score) + Math.Min(ordered.Count - 1, 2);
var representative = ordered[0].Message;
return new GmailJobMatchedThreadDto(
@@ -140,8 +159,10 @@ public sealed class GmailController : ControllerBase
threadScore,
ToConfidence(threadScore),
hasImportedMessages,
importedMessageCount,
ordered.Count,
latestDate,
matchedQueries,
combinedReasons,
ordered.Select(item => new GmailJobMatchedMessageDto(
item.Message.Id,
@@ -154,6 +175,7 @@ public sealed class GmailController : ControllerBase
item.Score,
ToConfidence(item.Score),
item.AlreadyImported,
item.MatchedQueries,
item.Reasons)).ToList());
})
.OrderByDescending(thread => thread.Score)
@@ -167,6 +189,8 @@ public sealed class GmailController : ControllerBase
job.Company?.RecruiterName,
job.Company?.RecruiterEmail,
queries,
rankedMessages.Count,
threads.Count,
threads));
}
@@ -379,39 +403,47 @@ public sealed class GmailController : ControllerBase
return queries.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
}
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailMessageSummary message, bool alreadyImported)
private static GmailScoredMessage ScoreMessage(JobApplication job, GmailQueryMatchedMessage candidate, bool alreadyImported, bool threadAlreadyImported)
{
var reasons = new List<GmailJobMatchReasonDto>();
var score = 0;
var message = candidate.Message;
var subject = message.Subject ?? string.Empty;
var from = message.From ?? string.Empty;
var to = message.To ?? string.Empty;
var snippet = message.Snippet ?? string.Empty;
var haystack = $"{subject} {from} {to} {snippet}";
if (candidate.MatchedQueries.Count > 0)
{
var queryHitPoints = Math.Min(12, candidate.MatchedQueries.Count * 4);
score += queryHitPoints;
reasons.Add(new GmailJobMatchReasonDto("queryHits", candidate.MatchedQueries.Count.ToString(), queryHitPoints));
}
if (!string.IsNullOrWhiteSpace(job.Company?.Name) && ContainsValue(haystack, job.Company.Name))
{
score += 18;
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim()));
reasons.Add(new GmailJobMatchReasonDto("company", job.Company.Name.Trim(), 18));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterEmail) && (ContainsValue(from, job.Company.RecruiterEmail) || ContainsValue(to, job.Company.RecruiterEmail)))
{
score += 20;
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim()));
reasons.Add(new GmailJobMatchReasonDto("recruiterEmail", job.Company.RecruiterEmail.Trim(), 20));
}
if (!string.IsNullOrWhiteSpace(job.Company?.RecruiterName) && ContainsValue(haystack, job.Company.RecruiterName))
{
score += 12;
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim()));
reasons.Add(new GmailJobMatchReasonDto("recruiter", job.Company.RecruiterName.Trim(), 12));
}
foreach (var token in SplitTerms(job.JobTitle).Take(4))
{
if (!ContainsValue(haystack, token)) continue;
score += 5;
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token));
reasons.Add(new GmailJobMatchReasonDto("jobTitle", token, 5));
}
foreach (var subjectLine in job.Messages
@@ -422,7 +454,7 @@ public sealed class GmailController : ControllerBase
{
if (!ContainsValue(subject, subjectLine!)) continue;
score += 8;
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim()));
reasons.Add(new GmailJobMatchReasonDto("existingSubject", subjectLine!.Trim(), 8));
}
if (message.Date is { } messageDate)
@@ -431,26 +463,34 @@ public sealed class GmailController : ControllerBase
if (ageDays <= 45)
{
score += 4;
reasons.Add(new GmailJobMatchReasonDto("recency", "45d"));
reasons.Add(new GmailJobMatchReasonDto("recency", "45d", 4));
}
else if (ageDays <= 180)
{
score += 2;
reasons.Add(new GmailJobMatchReasonDto("recency", "180d"));
reasons.Add(new GmailJobMatchReasonDto("recency", "180d", 2));
}
}
if (threadAlreadyImported && !alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "thread-already-imported", 0));
}
if (alreadyImported)
{
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported"));
reasons.Add(new GmailJobMatchReasonDto("status", "already-imported", 0));
}
reasons = reasons
.GroupBy(reason => new { reason.Label, reason.Value })
.GroupBy(reason => new { reason.Label, reason.Value, reason.Points })
.Select(group => group.First())
.OrderByDescending(reason => reason.Points)
.ThenBy(reason => reason.Label, StringComparer.Ordinal)
.ThenBy(reason => reason.Value, StringComparer.Ordinal)
.ToList();
return new GmailScoredMessage(message, alreadyImported, score, reasons);
return new GmailScoredMessage(message, alreadyImported, score, candidate.MatchedQueries, reasons);
}
private static bool ContainsValue(string haystack, string? value)
@@ -532,5 +572,6 @@ public sealed class GmailController : ControllerBase
GmailMessageSummary Message,
bool AlreadyImported,
int Score,
IReadOnlyList<string> MatchedQueries,
IReadOnlyList<GmailJobMatchReasonDto> Reasons);
}
+23 -6
View File
@@ -19,11 +19,13 @@ public interface IGmailOAuthService
Task DisconnectAsync(string ownerUserId, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesAsync(string ownerUserId, string? query, int maxResults, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken);
Task<GmailMessageDetail> GetMessageAsync(string ownerUserId, string messageId, CancellationToken cancellationToken);
}
public sealed record GmailOAuthExchangeResult(string GmailAddress);
public sealed record GmailMessageSummary(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet);
public sealed record GmailQueryMatchedMessage(GmailMessageSummary Message, IReadOnlyList<string> MatchedQueries);
public sealed record GmailMessageDetail(string Id, string ThreadId, string Subject, string From, string To, DateTimeOffset? Date, string Snippet, string BodyText, string? BodyHtml);
internal sealed class GmailTokenResponse
@@ -182,6 +184,12 @@ public sealed class GmailOAuthService : IGmailOAuthService
}
public async Task<IReadOnlyList<GmailMessageSummary>> ListMessagesForQueriesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
var matchedMessages = await ListJobCandidateMessagesAsync(ownerUserId, queries, maxResultsPerQuery, cancellationToken);
return matchedMessages.Select(static item => item.Message).ToList();
}
public async Task<IReadOnlyList<GmailQueryMatchedMessage>> ListJobCandidateMessagesAsync(string ownerUserId, IEnumerable<string> queries, int maxResultsPerQuery, CancellationToken cancellationToken)
{
maxResultsPerQuery = Math.Clamp(maxResultsPerQuery, 1, 25);
var normalizedQueries = queries
@@ -192,19 +200,28 @@ public sealed class GmailOAuthService : IGmailOAuthService
if (normalizedQueries.Count == 0)
{
return Array.Empty<GmailMessageSummary>();
return Array.Empty<GmailQueryMatchedMessage>();
}
var combined = new List<GmailMessageSummary>();
var combined = new Dictionary<string, (GmailMessageSummary Message, HashSet<string> Queries)>(StringComparer.Ordinal);
foreach (var query in normalizedQueries)
{
var items = await ListMessagesAsync(ownerUserId, query, maxResultsPerQuery, cancellationToken);
combined.AddRange(items);
foreach (var item in items)
{
if (!combined.TryGetValue(item.Id, out var existing))
{
combined[item.Id] = (item, new HashSet<string>(StringComparer.OrdinalIgnoreCase) { query });
continue;
}
existing.Queries.Add(query);
combined[item.Id] = existing;
}
}
return combined
.GroupBy(message => message.Id, StringComparer.Ordinal)
.Select(group => group.First())
return combined.Values
.Select(static entry => new GmailQueryMatchedMessage(entry.Message, entry.Queries.OrderBy(static query => query, StringComparer.OrdinalIgnoreCase).ToList()))
.ToList();
}
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<key id="9a89a42c-d2bd-4770-83fb-5930685432db" version="1">
<creationDate>2026-03-24T09:54:28.8487759Z</creationDate>
<activationDate>2026-03-24T09:54:28.8487759Z</activationDate>
<expirationDate>2026-06-22T09:54:28.8487759Z</expirationDate>
<descriptor deserializerType="Microsoft.AspNetCore.DataProtection.AuthenticatedEncryption.ConfigurationModel.AuthenticatedEncryptorDescriptorDeserializer, Microsoft.AspNetCore.DataProtection, Version=9.0.0.0, Culture=neutral, PublicKeyToken=adb9793829ddae60">
<descriptor>
<encryption algorithm="AES_256_CBC" />
<validation algorithm="HMACSHA256" />
<masterKey p4:requiresEncryption="true" xmlns:p4="http://schemas.asp.net/2015/03/dataProtection">
<!-- Warning: the key below is in an unencrypted form. -->
<value>LXbXqbpiEXn0OM6fr/TuXDBcZd83DvOInTI09PGZRr1Z20LQCD/PUKF1oo9UwC4O1VgK3wA//yxH9PPCIPzEaw==</value>
</masterKey>
</descriptor>
</descriptor>
</key>