# 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.

## Implemented canonical-hub unlink increment

- 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.
