# Email delivery simulation

Phase 3 milestone 9, built locally on web, iOS and Android. This is a retained **simulation ledger**, not an outbound mail queue. The API reports `mode: simulation`, `sending_enabled: false` and `delivery_status: not_sent` for every result. No SMTP connection, provider request, message ID, actual server acceptance or customer receipt is produced. There is no flag that turns this simulator into live sending.

## Staff workflow

1. Save a reply and obtain the separate-administrator approval described in [OUTBOUND-EMAIL.md](OUTBOUND-EMAIL.md).
2. Open **Email delivery simulation** in the same email conversation. Choose acceptance, rejection, uncertain outcome or interrupted attempt. Confirm that the exercise sends no email, then run it.
3. Read the result and expand its frozen email/history. The retained record points to the exact immutable approval revision, including recipient, proposed sender, subject, body and approver. Replacing the current outbound review cannot rewrite that evidence.
4. An interrupted attempt remains unfinished for a 60-second claim window. Refresh after expiry, confirm the simulation acknowledgement and choose **Record expired simulation as uncertain**. Nothing automatically retries or completes it.
5. An administrator may close an uncertain simulation after writing a review note (10–1,000 characters). This closes a synthetic exercise only; it is not proof about a real delivery. The claim cannot run again. A fresh preparation and separate approval are needed for another simulation.

Administrators and traders can start exercises and recover expired claims. Viewers can inspect retained results. Only administrators can close uncertainty. A pending/uncertain attempt blocks all new simulations in that conversation, even after replacement approval. All outcomes consume their approved revision: rejection, resolution or changed-context blocking does not grant a retry. This conservative first milestone deliberately has no automatic retry policy.

The UI distinguishes simulated acceptance from actual sending/receipt. It shows unavailable/expired states, clears failed reads, retains a failed command's retry key for an identical retry, blocks actions during unsaved conversation edits and renders snapshots and review notes as plain text. History is paginated, ten attempts per page; each has at most three retained state events. Staff can refresh the conversation, outbound review and simulation ledger after conflicts. Reloading or navigating can discard the in-memory key; database uniqueness still prevents rerunning an approved revision.

## Authorization, persistence and recovery

Starting requires the current conversation version and exact current approved outbound version. Under the same conversation/account lock order as review commands, the service rechecks the session, writer role, source context, sender, draft author, preparer and approver authority. An immutable approval history row is required. Each approval history ID can appear in the ledger only once.

The claim and its first event commit before the pure simulator runs. The simulator accepts only a fixed scenario, with no address, message, URL, credential or network adapter. Completion obtains the same locks, checks the unique claim token and 60-second deadline, then revalidates the session, initiating staff access version, exact approval and source authority/context. Changed context results in **blocked**, rather than simulated acceptance. An expired claim or simulator exception records **uncertain**, with no retry. A process failure after the durable claim leaves recoverable evidence. This is an explicit user-triggered exercise, not a background worker.

Recovery clears the claim token and records uncertainty. A delayed completion with the old token cannot overwrite recovery or resolution. Commands carry a per-actor UUID and payload hash; exact retries return the retained current result, while different content using the same key conflicts. Two simultaneous starts cannot both claim an approval. Generic workspace audit entries omit addresses, bodies and review-note text; the authorized staff ledger retains snapshots and notes.

A real transport would need a separately designed handoff boundary: once a remote server might have accepted a message, changing local approval cannot prove that it was not sent. This simulator's changed-context blocking and synthetic resolution must not be reused as claims about real SMTP outcomes. Real sending remains a separate implementation with verified sender configuration, threading/identifiers, commercial checks, reconciliation, bounce handling and explicitly authorized live tests.

## API and migration

- `GET /v1/inbox/{id}/outbound/simulations?offset=0&limit=10`: ledger, retained snapshots/events, current eligibility, block reason and server time. Maximum page size 50. GET never recovers or mutates a claim.
- `POST` to that path with `action: start`, `scenario`, `conversation_version`, `version` (approved outbound revision), `request_key` and strict `reviewed: true`.
- `POST` with `action: recover` or `resolve`, `simulation_id`, `version` (current simulation revision), `request_key` and strict `reviewed: true`. Resolution also requires `note`.

The discriminated request schema rejects arbitrary content, recipients, sender overrides, transport URLs and unknown actions/scenarios. The web proxy retains same-origin mutation checks; native uses its staff session. No `/send` endpoint exists.

Migration `ec51e39f2976` follows `db40d28e1865`, adding `email_simulations` and `email_simulation_events`. Existing approvals are not automatically simulated. Downgrade refuses to delete populated simulation history. Back up before migration. The schema was checked against SQLite and PostgreSQL, including restricted runtime-role reads/writes. Local preview and development databases were backed up under `.local/backups/before-email-simulation-20261012T222512Z/` and migrated; all 53 original table digests remained unchanged, FK checks passed and the two new tables started empty.

## Validation and remaining work

2026-10-13 focused validation: 43 SQLite backend cases for simulation and outbound review; 44 PostgreSQL-runner cases, of which 41 used disposable PostgreSQL schemas, two checked SQLite migrations and one used a separate restricted-role PostgreSQL database. Coverage includes concurrent identical/distinct starts, frozen historical content, retry conflicts, context/account/session changes during completion, expired/recovered claims, stale completion fencing, cross-conversation identity checks, administrator resolution and populated-migration preservation.

Six browser workflows passed (three simulation, three approval regressions), including phone-width display, literal markup, duplicate prevention, lost-response idempotency, viewer/origin restrictions, failed refresh and expired sessions. Shared contract tests, client typechecks and build/export results are recorded in [VALIDATION.md](VALIDATION.md). Physical devices, live SMTP, hosted operation and full release-candidate regression remain pending.

[Private outbound configuration and offline preflight](OUTBOUND-PREFLIGHT.md) are now built as milestone 10; live transport remains unavailable. [Offline reply threading and message formatting](EMAIL-FORMAT.md) are now implemented. The [synthetic SMTP rehearsal](SMTP-REHEARSAL.md) is now built as milestone 12. The [durable synthetic handoff rehearsal](SMTP-HANDOFF.md) is now built as milestone 13. [Outbound attempt history and administrator review](EMAIL-ATTEMPTS.md) are now built as milestone 14. [Sealed identity/reply matching](EMAIL-IDENTITY.md) and [application handoff rehearsal](EMAIL-HANDOFF.md) are now built as milestones 15–16; live dispatch remains disabled. Live email tests remain deferred by the owner. The legacy public enquiry form's SMTP settings are a separate system and were not reused or changed.
