# Email sandbox e2e End-to-end email tests against the **real** provider APIs. The main test suite stubs every email fetch. This harness sends real requests through the production delivery code and reports what each provider answered. It catches the one class of bug that stubs cannot: request URLs, auth headers, and body shapes that drift from what the providers accept. The harness runs the production code directly — `sendEmail` (`src/shared/email.ts`) for the single probe, and `sendBulkEmails` (`src/shared/email/bulk.ts`) for the bulk probe. It does not boot the app server or a browser, because outbound HTTP is the whole provider-facing surface for email. It is not a PR gate. See `.github/workflows/email-sandbox-e2e.yml` — it runs nightly and on demand. ## Running ```bash # Every provider leg (legs without secrets are skipped and reported): devenv shell deno task e2e:email # One provider: RESEND_API_KEY=re_... devenv shell -- deno task e2e:email resend ``` Exit codes: 0 = executed (each leg sent or skipped), 1 = failed. ## What each leg sends Each leg makes two real API calls with a unique run id in the subject: 1. A **single send** with an SVG attachment and a reply-to address — the request shape the registration and test emails use. 2. A **bulk send** with one recipient and an unsubscribe URL, through `sendBulkEmails`. This exercises the batch endpoint and the per-provider unsubscribe substitution. A leg passes when the provider accepts both requests and the bulk reply accounts for the probe message. `sendBulkEmails` reads Postmark's per-message results, because its batch endpoint can answer 200 and still refuse a message inside it. A reply that refuses the probe fails the leg. A reply that does not account for it fails the leg too, because an unconfirmed message is not a sent one. The harness reports what the production code found, and the refusal body when a request fails. Each leg has a two-minute allowance. A provider that stalls becomes a failed leg instead of a killed job, and the harness cancels the request it left in flight. ## Secrets per leg A leg runs only when its **on-switch** secret is set. A missing on-switch secret skips the leg and reports the skip. This differs from the payment harness on purpose: operators enable exactly the legs they hold credentials for. When the on-switch secret is set, a missing or invalid companion secret fails the run, because the operator clearly meant the leg to run. | Leg | On-switch | Companions | Recipient default | | ------------ | ----------------------- | ------------------------------------------ | -------------------------------- | | `mailgun-eu` | `MAILGUN_EU_API_KEY` | `MAILGUN_EU_FROM`, `MAILGUN_EU_TO` | none — set `MAILGUN_EU_TO` | | `mailgun-us` | `MAILGUN_US_API_KEY` | `MAILGUN_US_FROM`, `MAILGUN_US_TO` | none — set `MAILGUN_US_TO` | | `postmark` | `POSTMARK_SERVER_TOKEN` | `POSTMARK_FROM` (`POSTMARK_TO` optional) | `test@blackhole.postmarkapp.com` | | `resend` | `RESEND_API_KEY` | none (`RESEND_FROM`, `RESEND_TO` optional) | `delivered@resend.dev` | | `sendgrid` | `SENDGRID_API_KEY` | `SENDGRID_FROM` (`SENDGRID_TO` optional) | `e2e@sink.sendgrid.net` | Every message goes to a safe recipient, so no person receives nightly mail: - **Mailgun** has no public discard address. A sandbox domain only delivers to its authorized recipients, so authorize a throwaway address and set it as `MAILGUN_*_TO`. The from address decides the API domain, so `MAILGUN_*_FROM` must be an address on a domain in that account (the sandbox domain works). The two regions are separate accounts with separate API hosts, so each has its own secrets. - **Postmark** documents `test@blackhole.postmarkapp.com`: the API accepts the message, shows it in Activity, and discards it. With a real server token, `POSTMARK_FROM` must be a verified sender signature. The public `POSTMARK_API_TEST` token also works as the on-switch and validates requests without an account. - **Resend** documents `delivered@resend.dev` for delivery simulation and `onboarding@resend.dev` as the sender for accounts with no verified domain. Both are the defaults, so the API key alone enables the leg. - **SendGrid** documents the `sink.sendgrid.net` domain: messages are accepted, then discarded. `SENDGRID_FROM` must be a verified sender identity. `SENTRY_URL` is optional. A harness crash reports the code error to that bug catcher, as the payment harness does. A planned leg failure (a provider refusal) stays in the job log: it is the run's normal way to fail, so only the red CI job reports it. ## Reporting The run prints one line per leg, appends a provider/outcome/detail table to the GitHub Actions step summary, and publishes `result=executed` for the workflow's verify step. A failed leg fails the run after every leg reported. ## Layout ``` scripts/email-sandbox-e2e/ main.ts entrypoint: target parse, leg loop, report, exit status legs.ts the per-provider secret table and env resolution run.ts the two probes, sent through the production email code report.ts console lines, Markdown summary, verdict ``` The shared harness pieces live in `e2e-payments/src`: `log.ts` (output), `sentry.ts` (the crash report to the bug catcher), `entry.ts` (the crash boundary), and `randomId` (`config.ts`). `scripts/github-actions.ts` writes the step summary and the job output for both harnesses and the mutation runner.