Files
jobtrackingapp/docs/verification/mail-001-job-email-hub.md
T
cesnimda e7cacad7d6
CI and Deploy / test (pull_request) Failing after 2m46s
CI and Deploy / deploy (pull_request) Has been skipped
fix(scale): page mail and batch roles
2026-08-15 21:02:50 +02:00

24 KiB
Raw Blame History

MAIL-001 consolidated job-email hub

Updated: 2026-08-15

Status: IN PROGRESS. Canonical hub routing, provider-neutral reads, explicit-confirmed send API, persisted reply/new-message UI, interrupted-send recovery, legacy SMTP retirement, send-attempt/draft export coverage, shared application context and tenant-owned draft persistence/API are implemented and locally verified; remaining provider mailbox actions and full account-deletion lifecycle remain.

Revalidated current boundaries

  • /correspondence listed persisted, job-linked Correspondence rows with search/direction/link filters.
  • /correspondence/review separately rendered Gmail review candidates/suggested jobs and linked back to the inbox.
  • The job workspace already embeds the shared Correspondence component and therefore reads/writes the same underlying rows rather than a copy.
  • Gmail review decisions, import/link/unlink/relink and job creation use existing Gmail APIs. Outlook/Graph and IMAP connection models exist, but this review surface is Gmail-specific.
  • The per-job composer currently logs a message to Correspondence; it is not a provider-send draft flow. Separate follow-up sending exists elsewhere and must not be mislabeled or silently reused.

Implemented first increment

  • /correspondence is the canonical Job email hub with linked-message and recruitment-suggestion views represented by ?view=review.
  • The review component embeds under the hub with correct heading hierarchy and without duplicate back navigation.
  • /correspondence/review remains a compatibility route and redirects to the canonical filtered hub.
  • Switching to review does not issue the linked-correspondence query; switching views reuses the existing tested review component and APIs.
  • No provider connection, sync, import, link or send behavior changed.

Implemented provider-neutral read increment

  • Added one authenticated /api/email controller over the existing IEmailProviderRegistry for provider status, search, thread summaries and plain-text message detail.
  • Every operation passes the authenticated owner ID into the registered Gmail, Outlook or IMAP adapter and rejects unknown or disconnected providers before mailbox access.
  • Message detail intentionally omits provider HTML. Untrusted provider markup is not exposed through this shared endpoint.
  • The hub now identifies connected and disconnected providers and advertises their actual capability. Gmail, Outlook and IMAP are currently shown as read-only because their installed scopes/contracts do not implement provider send.
  • The controller does not change OAuth scopes, connect accounts, invoke providers in tests or claim that the legacy SMTP follow-up sender is provider-native.

Implemented safe message-detail increment

  • Provider-backed rows now open plain-text detail through /api/email/message; the UI never renders provider HTML.
  • If a provider is disconnected or unavailable, the hub clearly warns and shows the owner-scoped saved JobTracker copy instead of losing access to imported correspondence.
  • Manual/internal rows use the same detail shape through /api/correspondence/message/{id}.
  • Direct saved-message IDs remain tenant-filtered. Malformed legacy label/attachment JSON degrades to empty metadata instead of breaking the message view.
  • Rapid selection changes invalidate older requests so late provider responses cannot appear under the wrong message.
  • Inbox label and attachment counts now reflect parsed metadata arrays rather than treating every non-null JSON field as one item.

Implemented inert send-ledger increment

  • Added tenant-owned EmailSendAttempts with pending/sending/sent/failed/uncertain states and a unique owner/client-request key.
  • A request ID can be reused only for the same SHA-256 payload hash. Different content under an old request ID is rejected.
  • Only pending attempts may enter sending, and only sending attempts may become terminal. Failed or uncertain attempts cannot be restarted blindly.
  • The ledger stores provider/idempotency/status/timing metadata only; recipient, subject and message body are intentionally absent.
  • Deleting the owning job cascades the ledger row. The global owner filter protects direct attempt IDs.
  • The additive migration has provider-specific SQLite/MariaDB types and reversible up/down SQL. No send route, OAuth scope or provider call was enabled.

Implemented provider-delivery adapter increment

  • Gmail and Microsoft Graph authorization URLs now request explicit send consent in addition to read access. Existing read-only connections remain read-only until the user reconnects.
  • Provider status derives send capability from the stored granted scope. IMAP remains read-only because it has no configured outgoing transport.
  • Gmail builds an RFC MIME plain-text message, supports the existing Gmail thread ID, and uses the documented send endpoint. Graph sends plain-text JSON through sendMail.
  • HTTP rejection is a known failed-before-delivery category; 401/403 requires reauthorization. Network interruption/cancellation is marked uncertain because acceptance cannot be disproved.
  • Provider response bodies and transport exception details are not returned to callers. Recipient/body fixtures and HTTP transport are synthetic/mocked; no provider was contacted.
  • The adapters are reachable only through the later explicit-confirmed API; no JobTracker send button exists yet.

Implemented explicit-send API increment

  • Added one authenticated, rate-limited POST /api/email/send route. It requires an owned job, a send-capable connected provider, an explicit confirmed=true, and valid bounded recipient/subject/body/thread fields.
  • Client UUIDs are canonicalized before the tenant ledger reservation. Reusing a UUID with different content is rejected; sent duplicates return the original result; pending, failed or uncertain attempts are never redelivered automatically.
  • The ledger is reserved and moved to sending before provider I/O. Provider rejection is failed, transport ambiguity is uncertain, and connection failure before delivery is failed.
  • Successful delivery writes the outbound correspondence, a content-free job event and the ledger terminal state in one local database transaction. Provider acceptance followed by local persistence failure is surfaced as uncertain.
  • The audit event and ledger omit recipient, subject and body. Full content exists only in the intended job correspondence record.
  • Tests use owner-isolated SQLite and a fake provider; no email, OAuth flow, provider service or external network was invoked.

Verification

  • Focused delivery/provider/capability: 18/18; send ledger: 3/3; provider/correspondence controllers: 5/5; hub detail: 5/5.
  • Explicit-send controller/store/read focused tests: 12/12.
  • Confirmed composer focused tests: 7/7.
  • Current full backend: 625/625; full frontend: 50/50 suites, 194/194 tests.
  • Production build/TypeScript and git diff --check: pass.
  • Implementation commits: 6008b4a, 536d403, a20775c, 653f011, e9937ac, 123fc55, 449faeb, ee5ef7e, 8fe3903, aff34cc, ff547df, 1dabbeb, f9e641c, 7f41cb2, 14b396a.

Implemented editable composer increment

  • Provider-backed message detail offers Reply only when that exact connected provider has send consent. Read-only connections show an explicit reconnect requirement; manual correspondence is not mislabeled as provider-send capable.
  • Recipient, subject, thread, provider/from account and body remain visible before sending. Recipient, subject and body remain editable; the provider and provider thread remain fixed to avoid cross-provider thread claims.
  • The app-owned confirmation dialog identifies provider/from address, recipient, subject and thread. Cancelling leaves the draft intact and invokes no API.
  • One UUID remains stable through pre-send edits. A failed attempt requires an explicit new attempt; an uncertain/pending/sending result disables retry and tells the user to inspect the provider Sent folder.
  • A network/unknown server interruption is treated as uncertain on the client. Successful sends refresh the same correspondence domain; no second copy or autonomous AI/send path was added.
  • Draft state is in-memory for this increment. Navigation within the hub preserves it, but refresh persistence and compose-new-message flow remain separate work.

Implemented interrupted-send recovery increment

  • A safety worker starts only after database readiness and checks every five minutes. It has no email-provider, SMTP or message-content dependency.
  • Attempts still pending after 15 minutes are definitively failed as stopped before provider delivery. Attempts still sending after 15 minutes become uncertain; neither state is queued or retried.
  • Recovery uses conditional updates inside the local database transaction, so overlapping replicas can observe the same candidate but only one changes it and creates the notification.
  • Each affected owner receives one generic, content-free notification linked to Job email. The notification never contains provider, recipient, subject or body.
  • A real-SQLite two-owner clock/restart test proves stale/fresh separation, owner-visible notifications, repeat-run idempotency and no transition back to pending/sending.

Implemented legacy SMTP retirement increment

  • The Follow up tab still generates, displays, edits and copies grounded drafts, but it no longer presents a direct application-SMTP send action.
  • Its outbound action opens canonical Job email, where the connected provider/from identity and final confirmation are visible before delivery.
  • The old POST /api/jobapplications/{id}/send-followup boundary returns 410 Gone and has no IAppEmailSender dependency. It cannot deliver, create a sent correspondence or mutate follow-up/contact dates.
  • Scheduled reminder notification email remains on its separate worker path and was not removed or redirected.
  • Focused backend follow-up/worker tests pass 10/10; focused UI trust-loop tests pass 2/2. Full backend 621/621, frontend 190/190, build and audit gates pass across the combined working state.

Implemented send-attempt export/cascade increment

  • Encrypted on-demand backups and tenant-isolated daily JSON exports now include provider delivery history using one explicit content-free shape.
  • Exported attempt fields cover job/provider/request/status/provider-message/failure-category and timestamps. The internal payload hash is excluded; recipient, subject and body were never stored on the ledger.
  • Both queries stay inside the current owner filter and the owner's exported job IDs. Daily two-owner fixtures prove one isolated attempt per file and hashed filenames.
  • Real SQLite proves a hard delete of one owned job cascades only its send attempts and preserves another owner's job/attempt.
  • This does not implement complete account deletion. Identity-row deletion still lacks the cross-store/database/file lifecycle owned by SEC-009 and remains a release blocker.

Implemented shared application-context increment

  • The job dialog and dedicated Application Workspace now pass the same small company/recruiter/role context contract into the shared Correspondence component.
  • Removed the workspace's null as any job placeholder. The dedicated communication section now produces the same company/role Gmail suggestions and visible matching context as the job dialog once its owner-scoped overview loads.
  • Suggestion construction remains bounded and explicitly optional: missing context produces no invented company/recruiter query, duplicate saved subjects are collapsed, and no message is linked automatically.
  • This does not broaden Gmail/Graph scopes or claim read/unread/archive/spam/trash mutation support. Current installed provider contracts remain read plus explicit-confirmed Gmail/Graph send only.
  • Focused correspondence/context tests pass 10/10; full frontend passes 50/50 suites and 192/192 tests; production build/TypeScript passes.
  • Gmail-linked rows in the canonical Job email hub now expose the same existing unlink domain used by the per-job view; Outlook, IMAP and manual rows do not display a capability they lack.
  • Unlink requires the app-owned destructive confirmation and states that only the JobTracker link/import is removed; the provider copy is not deleted.
  • A confirmed action returns the thread to recruitment review, refreshes the canonical inbox and clears any open detail for the removed row. Cancelling invokes no API.
  • The existing API resolves the job through the authenticated owner before deleting linked rows. A real-database two-user regression proves User A receives not-found and cannot remove User B's correspondence or create a review decision.
  • Focused hub UI passes 8/8 and unlink API passes 2/2; full backend passes 623/623, full frontend 50/50 suites and 193/193 tests, and production build/TypeScript passes.

Implemented honest provider-state increment

  • Disconnected providers are now labelled only as not connected; the hub no longer misleadingly calls them read-only.
  • Connected read-only accounts identify that reconnect consent is required to enable send, while send-capable accounts report read plus send. A connected record without read capability is labelled unavailable rather than usable.
  • Failure of the provider-status endpoint is no longer silent. The hub shows a warning while keeping owner-saved JobTracker correspondence available and usable.
  • No provider scope, mailbox mutation or connection state changed. Focused hub tests pass 9/9, full frontend passes 50/50 suites and 194/194 tests, and production build/TypeScript passes.

Verified Free non-AI access

  • The provider read/send controller requires authenticated local application access but has no Pro policy on the class or send action. A regression test pins both sides of this boundary.
  • The send path still requires an owned job, connected send-capable provider, explicit confirmation and idempotency; Free access does not weaken those controls.
  • The hub has no AI-assistance action today, so there is no AI email feature to mislabel as Free or Pro. Any future AI drafting must use the existing Pro/privacy admission boundary without changing basic email access.
  • Focused send tests pass 7/7 and full backend passes 624/624.

Implemented inert durable-draft persistence

  • Added a dedicated owner-filtered EmailDraft model for provider, job, recipient, subject, plain-text body, optional thread, timestamps and optimistic revision metadata. It does not reuse recruiter drafts, correspondence or browser storage.
  • The owning job has a cascade relationship; a real-SQLite two-owner test proves User A sees only User A's draft and deleting User A's job removes only that draft while preserving User B's data.
  • The additive migration has explicit SQLite and MariaDB types plus reversible down SQL. EF reports the model current; backend passes 625/625 and both provider scripts generate successfully.
  • No route, UI, provider call, token or content log was added. Export coverage and the complete SEC-009 deletion lifecycle remain prerequisites before private draft content becomes reachable.
  • The repaired historical chain now reaches this migration from a blank standalone SQLite database and remains idempotent. A populated older checkpoint also preserves job data through the chain (V-186); production migration remains gated.

Implemented readable draft export coverage

  • Authenticated encrypted backups and the existing per-owner daily JSON export now include one explicit readable draft shape: job/provider/recipient/subject/plain body/thread/revision/timestamps.
  • Both paths query through the owner filter and restrict drafts to the already exported owned job IDs. Synthetic two-owner tests prove the on-demand backup excludes another tenant and each hashed daily file contains only its matching owner's content.
  • Focused export tests pass 4/4 and the full backend remains 625/625. No new public route, log, provider call or browser storage was added.
  • Daily files inherit the existing export-folder protection and retention boundary. Complete live/export/backup deletion and retention remain SEC-009 work, not an implied guarantee from MAIL-001.

Implemented bounded draft API

  • Added local-authenticated list/get/create/update/delete routes under /api/email/drafts; all queries use the authenticated owner and the global tenant filter.
  • Creation requires an owned job and registered provider but permits empty recipient/subject/body for incomplete autosave. Fields remain bounded, a non-empty recipient must be valid, and provider/thread/job provenance cannot be rewritten after creation.
  • Updates and deletes require the caller's current revision and execute atomically; stale writes return a reload conflict rather than silently overwriting newer content.
  • Real-SQLite tests cover foreign job creation, direct foreign IDs, foreign list/update/delete attempts, stale updates/deletes and preservation of the other tenant. Focused 4/4, backend 629/629 and build pass.
  • Saving a draft never calls a provider or send path. Delivery still requires the separate connected/send-capable, explicit-confirmed, idempotent API.
  • Every created draft now owns a canonical client-request UUID that survives edits and refresh and is included in the user export. This prevents a restored draft from silently obtaining a fresh ledger identity and bypassing duplicate-send protection.
  • The API can list all drafts for the authenticated owner to support refresh recovery; foreign drafts remain absent under both explicit owner predicates and the global filter.

Implemented explicit reply-draft recovery UI

  • Reply drafts stay local until Save draft is selected, then use the server ID/revision/client-request identity. Incomplete replies can be saved without weakening the stricter send validation.
  • Saved drafts appear in Job email after refresh and can be resumed. A multi-tab 409 leaves the current text visible and instructs the user to reload instead of overwriting the newer version.
  • Discard and successful-send cleanup use the current revision. If cleanup finds a newer revision after send, the UI warns rather than deleting the newer draft; the stable client-request ID still prevents a second delivery attempt.
  • Focused UI passes 11/11, full frontend 50 suites/196 tests and the production build pass. These are mocked/JSDOM claims only; browser/provider/production remain gated.

Implemented definitive-failure draft rotation

  • A saved draft receives a new delivery UUID only through an explicit revisioned action and only when its current UUID matches the authenticated owner's failed ledger attempt.
  • Missing attempts, stale revisions and foreign draft IDs are refused. Pending, sending, uncertain and sent states never satisfy the terminal-failure predicate and cannot be made retryable through this route.
  • Prepare new attempt now persists the rotated UUID/revision before re-enabling send. Draft API passes 5/5, backend 630/630, inbox 12/12, full frontend 197/197 and build pass.

Implemented new-message drafting

  • Compose new email loads the authenticated user's recent job list and offers only connected providers that report send consent. Read-only/disconnected providers are absent from the selector and the send API still rechecks connection capability.
  • The job and provider selectors have explicit accessible labels. Missing jobs and missing send consent produce visible guidance instead of a non-explanatory disabled path.
  • Starting creates a blank local threadless draft; Save draft and Review and send reuse the same bounded persistence, UUID, revision conflict, confirmation and delivery paths as replies.
  • The compose and linked-thread move selectors use an owner-filtered server search rather than pretending a large page size is exhaustive. Search evaluates all owned, non-deleted applications and returns bounded compact choices. Focused correspondence passes 20/20, the owner/isolation endpoint slice passes 4/4, and the production build passes.

Remaining MAIL-001 work

  • Extend shared provider-neutral thread navigation while preserving provider capability differences; application context and Gmail unlink are now shared.
  • Add read/unread, pin/read-later/archive/spam/trash only where the provider supports it; identity, disconnected/read-only/send-capable and provider-status failure states are now explicit.
  • Share thread detail and link/unlink actions between hub and job workspace.
  • Provider mailbox category mutations remain absent because installed scopes/contracts do not authorize them. Repository reply/new-message draft flows are implemented; browser/provider/production gates remain.
  • Complete account deletion coverage under SEC-009 before production rollout; job-level hard-delete cascade and export coverage are verified.
  • Preserve minimal audit metadata without sensitive body logging. Free non-AI access is verified; future AI assistance remains a Pro/privacy-gated addition, not a prerequisite for basic email.
  • Complete remaining link/unlink/dismiss/draft/send/failure/two-user browser/production provider gates. Shared application-context behavior is now covered locally. No real email may be sent during repository verification.

Implemented complete inbox pagination

  • The current Job email UI uses an additive /api/correspondence/page contract with bounded page sizes, total/page metadata and deterministic date/ID ordering. The original endpoint remains for compatibility rather than changing an existing client contract in place.
  • A 205-message tenant fixture proves the third page returns the final five owned messages and excludes another tenant. The UI exposes accessible page navigation, resets to page one when a filter changes and reports the complete filtered total rather than the current page count.
  • Focused backend/admin tests pass 9/9, correspondence UI 15/15, full backend 683/683, full frontend 58 suites/239 tests and the optimized production build passes. Provider and production mailbox performance remain unmeasured; no provider or email was contacted.

Validation limitation

The first focused Jest invocation exhibited the repository's open-handle delay. The passing focused and full runs used --forceExit; the full run took 228.709 seconds. A Next build process also failed to exit after compilation; only the exact PIDs started by those build attempts were stopped, then a clean build completed. This is recorded as tooling/runtime behavior, not hidden.

Local browser evidence

  • A disposable local @example.test user reached /correspondence in a running frontend/API browser session.
  • The empty linked-message view rendered one H1, search/direction/link filters, refresh, zero-state guidance and explicit disconnected/read-only status for Gmail, Outlook and IMAP with no horizontal overflow at the available 1280×720 viewport.
  • Review suggestions changed the URL to ?view=review; direct /correspondence/review reached the same canonical filtered view after its compatibility redirect.
  • Disconnected Gmail produced the intended visible connect requirement. Server logs showed the expected 409 review responses and successful provider-status/local-domain requests, with no 5xx in the exercised path.
  • Screenshots: docs/audits/evidence/mail-001/job-email-empty-connected-status-20260810.png and job-email-review-disconnected-20260810.png.
  • The browser surface could not resize or produce native Tab traversal, so 375/768/1440, theme and keyboard/focus claims remain blocked. No provider was connected and no message was sent.

Rollback

Revert ee5ef7e to stop recovery and 449faeb to remove the composer, then disable admission. Revert 123fc55 to remove the send route, e9937ac for send consent/adapters and 653f011 (after migration downgrade) for the ledger, followed by earlier read/routing commits. Existing provider grants are not revoked by a code rollback; disconnect/reconnect is an explicit user action.