Files
jobtrackingapp/docs/verification/mail-001-job-email-hub.md
T
cesnimda 3f0eba0525
CI and Deploy / test (pull_request) Failing after 4m24s
CI and Deploy / deploy (pull_request) Has been skipped
docs(email): record export lifecycle evidence
2026-08-10 00:49:49 +02:00

12 KiB

MAIL-001 consolidated job-email hub

Updated: 2026-08-10

Status: IN PROGRESS. Canonical hub routing, provider-neutral reads, explicit-confirmed send API, editable reply UI, interrupted-send recovery, legacy SMTP retirement and send-attempt export coverage are implemented and locally verified; remaining provider 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.
  • Full backend: 620/620; full frontend: 49/49 suites, 190/190 tests.
  • Production build/TypeScript and git diff --check: pass.
  • Implementation commits: 6008b4a, 536d403, a20775c, 653f011, e9937ac, 123fc55, 449faeb, ee5ef7e, 8fe3903, aff34cc.

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.

Remaining MAIL-001 work

  • Extend shared provider-neutral thread navigation and application embedding while preserving provider capability differences.
  • Surface provider identity, reauthorization, read/unread, pin/read-later/archive/spam/trash only where the provider supports it.
  • Share thread detail and link/unlink actions between hub and job workspace.
  • Add durable draft/refresh recovery and compose-new-message behavior if confirmed by the remaining workflow inventory; replies are implemented.
  • 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; verify Free non-AI access and Pro-only AI assistance.
  • Complete link/unlink/dismiss/draft/send/failure/two-user/application-embed tests plus browser/production provider gates. No real email may be sent during repository verification.

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.

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.