Skip to content

test(ci): replace mailsac with self-owned e2e inbox worker - #3574

Open
baktun14 wants to merge 9 commits into
mainfrom
test/ci-replace-mailsac-e2e-inbox
Open

test(ci): replace mailsac with self-owned e2e inbox worker#3574
baktun14 wants to merge 9 commits into
mainfrom
test/ci-replace-mailsac-e2e-inbox

Conversation

@baktun14

@baktun14 baktun14 commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Why

Resolves CON-799

The console-web release pipeline is gated by Playwright e2e tests that read Auth0 passwordless OTP emails through Mailsac. Mailsac's free tier allows 1,500 API ops/month and we exceeded it (126.7% last month), so codes stop arriving and release runs fail (e.g. run 31184065073). This recurs every month the suite stays on Mailsac.

What

Replaces Mailsac with email-capture infrastructure we own:

  • akash-network/e2e-inbox-worker (new standalone repo): Cloudflare Email Worker + D1. A catch-all Email Routing rule on a dedicated e2e domain delivers Auth0's OTP emails to the worker, which parses them with postal-mime and serves them via a bearer-token-protected GET /messages/:email. Its README contains the one-time setup runbook and local-dev instructions. Verified locally via wrangler dev + /cdn-cgi/handler/email injection (plain-text and HTML-only emails, auth 401s, unknown-path 404). The worker initially lived in this PR under tools/e2e-inbox-worker/ and was moved to its own repo per review feedback, so its dependencies sit inside a scanned surface (own CI + CodeQL + Dependabot; Socket/Snyk enablement tracked in that repo's README checklist) with its own secrets and auto-deploy.
  • e2e suite: the Mailsac strategy's generic OTP poll-and-submit logic moves to InboxCodeVerificationStrategy (parameterized by an InboxClient); the new WorkerInboxClient talks to the worker. Mailsac strategy and MAILSAC_API_KEY are deleted; env now needs E2E_INBOX_API_URL, E2E_INBOX_API_TOKEN, E2E_INBOX_EMAIL_DOMAIN (fail-fast zod validation).
  • CI: console-web-ui-testing action and reusable-deploy-test.yml pass the three new values; the vestigial EMAIL_VERIFICATION_STRATEGY input (never read by test code) is removed.

Before merge (infra, one-time): register a dedicated e2e mail domain as a Cloudflare zone with Email Routing, wrangler d1 create + deploy the worker, add the catch-all rule, then set this repo's GitHub secret E2E_INBOX_API_TOKEN and variables E2E_INBOX_API_URL / E2E_INBOX_EMAIL_DOMAIN (runbook in the e2e-inbox-worker README). MAILSAC_API_KEY can be deleted after the first green release run.

No shipped-artifact changes; the flow still exercises real Auth0 email delivery end-to-end.

Summary by CodeRabbit

  • New Features

    • Added a secure email inbox service for passwordless verification and one-time passcode retrieval.
    • Added configurable inbox connection settings for automated testing environments.
    • Added automatic email storage, text extraction, message lookup, authorization, and cleanup of older messages.
    • Replaced legacy email verification configuration with the new inbox service.
  • Documentation

    • Added setup, deployment, local development, and usage guidance.
    • Updated passwordless authentication testing guidance.
  • Tests

    • Added coverage for inbox processing, verification flows, configuration validation, error handling, and automated service checks.

@coderabbitai

coderabbitai Bot commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds an authenticated Cloudflare E2E inbox worker with D1 storage. UI email verification now uses the worker through shared client and strategy abstractions. GitHub Actions and test configuration use the worker URL, token, and email domain instead of Mailsac.

Changes

E2E inbox worker migration

Layer / File(s) Summary
Worker storage and API
tools/e2e-inbox-worker/*
Adds email parsing, normalized D1 storage, 24-hour cleanup, authenticated message retrieval, Wrangler configuration, package scripts, and setup documentation.
Inbox client and verification strategy
apps/deploy-web/tests/ui/services/email-verification/*
Adds the InboxClient contract and WorkerInboxClient. Updates OTP polling to use inbox messages, cached bodies, numeric timestamps, and injected clients.
Test configuration and authentication wiring
.github/actions/..., .github/workflows/..., apps/deploy-web/tests/ui/...
Replaces Mailsac inputs and environment variables with E2E inbox settings. Passwordless registration and sign-in use the shared verification strategy factory.
Worker and verification test coverage
apps/deploy-web/tests/ui/services/email-verification/*.unit.spec.ts, tools/e2e-inbox-worker/src/index.spec.ts, apps/deploy-web/*
Adds unit and integration tests for inbox verification, worker client requests, email parsing, authorization, cleanup, ordering, and test-runner separation.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

  • akash-network/console#3178: Updates the passwordless login tests and email-verification strategy that this PR migrates from Mailsac.
  • akash-network/console#3400: Overlaps in the UI email-verification code, including auth.ts, test-env.config.ts, and createEmailVerificationStrategy().
  • akash-network/console#3493: Relates to the shared passwordless-login E2E tests and email-verification flow.

Suggested reviewers: stalniy

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch test/ci-replace-mailsac-e2e-inbox

Comment @coderabbitai help to get the list of available commands.

@codecov

codecov Bot commented Aug 9, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 75.73%. Comparing base (44bef1f) to head (5819085).
⚠️ Report is 2 commits behind head on main.
✅ All tests successful. No failed tests found.

Additional details and impacted files
@@            Coverage Diff             @@
##             main    #3574      +/-   ##
==========================================
- Coverage   76.33%   75.73%   -0.61%     
==========================================
  Files        1134     1070      -64     
  Lines       29598    27685    -1913     
  Branches     7352     6958     -394     
==========================================
- Hits        22593    20966    -1627     
+ Misses       6174     5915     -259     
+ Partials      831      804      -27     
Flag Coverage Δ *Carryforward flag
api 88.97% <ø> (+0.04%) ⬆️
deploy-web 66.19% <ø> (ø)
log-collector ?
notifications 93.84% <ø> (ø)
provider-console 81.38% <ø> (ø) Carriedforward from 27a3350
provider-inventory ?
provider-proxy 88.17% <ø> (ø) Carriedforward from 27a3350
tx-signer 86.72% <ø> (ø)

*This pull request uses carry forward flags. Click here to find out more.
see 69 files with indirect coverage changes

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/deploy-web/tests/ui/fixture/test-env.config.ts`:
- Around line 18-22: Update the E2E_INBOX_API_URL schema validation to require
an absolute URL using https, while allowing http only for explicit loopback
hosts used in local development. Preserve the existing trimming and
trailing-slash normalization, and reject all other schemes or non-loopback http
addresses.

In `@apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts`:
- Around line 32-44: Update fetchMessages to use an AbortController and pass its
signal to fetch with a bounded timeout; clear the timeout in a finally block so
stalled Worker requests abort and polling can resume.

In `@tools/e2e-inbox-worker/src/index.ts`:
- Around line 18-20: The message lookup SELECT must filter out records older
than MESSAGE_TTL_MS, and expired rows must be purged on a scheduled path
independent of new email delivery. Update the existing cleanup flow in the
worker’s request/handler logic and add the scheduled purge hook, then revise the
README retention description to document read-time exclusion and periodic
deletion.

In `@tools/e2e-inbox-worker/wrangler.jsonc`:
- Around line 10-11: Replace the all-zero database_id placeholder in the
Wrangler configuration with the actual UUID returned by `wrangler d1 create
console-e2e-inbox`, ensuring deployment binds DB to the intended remote D1
database.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8b2919b0-3707-4a30-a38d-d21e273f87c0

📥 Commits

Reviewing files that changed from the base of the PR and between 44bef1f and 79bfc0a.

⛔ Files ignored due to path filters (1)
  • tools/e2e-inbox-worker/package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (18)
  • .github/actions/console-web-ui-testing/action.yml
  • .github/workflows/reusable-deploy-test.yml
  • apps/deploy-web/tests/ui/actions/auth.ts
  • apps/deploy-web/tests/ui/fixture/test-env.config.ts
  • apps/deploy-web/tests/ui/onboarding-journey.spec.ts
  • apps/deploy-web/tests/ui/passwordless-login.spec.ts
  • apps/deploy-web/tests/ui/services/email-verification/email-verification.strategy.ts
  • apps/deploy-web/tests/ui/services/email-verification/inbox-client.ts
  • apps/deploy-web/tests/ui/services/email-verification/inbox-code.strategy.ts
  • apps/deploy-web/tests/ui/services/email-verification/index.ts
  • apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts
  • tools/e2e-inbox-worker/.gitignore
  • tools/e2e-inbox-worker/README.md
  • tools/e2e-inbox-worker/package.json
  • tools/e2e-inbox-worker/schema.sql
  • tools/e2e-inbox-worker/src/index.ts
  • tools/e2e-inbox-worker/tsconfig.json
  • tools/e2e-inbox-worker/wrangler.jsonc

Comment thread apps/deploy-web/tests/ui/fixture/test-env.config.ts Outdated
Comment thread apps/deploy-web/tests/ui/services/email-verification/worker-inbox.client.ts Outdated
Comment thread tools/e2e-inbox-worker/src/index.ts Outdated
Comment thread tools/e2e-inbox-worker/wrangler.jsonc Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it's a large (XL) change that introduces new e2e test infrastructure (a Cloudflare Email Worker + D1) and touches the release-gating CI pipeline/secrets, a human look would still be worthwhile before merging.

What was reviewed:

  • The worker's email parsing/storage flow and its bearer-token-protected GET /messages/:email endpoint (tools/e2e-inbox-worker/src/index.ts)
  • The refactor from MailsacCodeVerificationStrategy to the generic InboxCodeVerificationStrategy + WorkerInboxClient, and the freshness/polling logic that moved over unchanged
  • CI wiring in action.yml and reusable-deploy-test.yml swapping Mailsac secrets/vars for the new E2E_INBOX_* ones
  • Candidate issues around the text-body fallback, HTML-to-text script handling, and fetchMessageBody's unused parameter were raised and ruled out as non-issues
Extended reasoning...

Overview

This PR replaces a third-party mailbox service (Mailsac) with a self-hosted Cloudflare Email Worker + D1 database for reading Auth0 passwordless OTP emails in the deploy-web Playwright e2e suite. It touches: a new tools/e2e-inbox-worker package (Worker source, schema, wrangler config, README runbook), the e2e test helper classes (renamed/refactored Mailsac strategy into a generic inbox-client abstraction), the env schema for the e2e suite, and two CI files (composite action + reusable workflow) that pass new secrets/vars instead of the old Mailsac ones.

Security risks

The new Worker exposes a bearer-token-protected HTTP endpoint that returns captured email bodies for a given address. The token comparison is a direct string equality check rather than constant-time, but this is a low-value target (test-only OTP mailbox scoped to a throwaway e2e email domain, not production data or credentials), so the risk is minimal. No production code paths, auth logic, or user-facing security surfaces are touched — this is purely CI/test infrastructure, and the shipped app has no changes.

Level of scrutiny

This is test/CI infrastructure rather than product code, which normally warrants lighter scrutiny. However, it's large (19 files, size:XL), introduces an entirely new deployed service with manual one-time infra setup (Cloudflare Email Routing, D1, secrets) that lives outside this repo's normal deploy pipeline, and modifies the release-gating CI workflow (reusable-deploy-test.yml) and its secrets. That combination — new external infra + CI pipeline changes for a release-gating suite — justifies a human sanity check even though no functional bugs were found in the code itself.

Other factors

The refactor of the OTP polling/matching logic (InboxCodeVerificationStrategy) is a fairly mechanical extraction of the existing Mailsac logic behind a new InboxClient interface, and looks correct. The bug-hunting pass raised a few candidate issues (blank text/plain fallback, script-tag leakage in the HTML-to-text fallback, and an apparently-unused parameter in WorkerInboxClient.fetchMessageBody) which were investigated and ruled out. No outstanding review comments exist on this PR to address.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Beyond the two nits already flagged inline, I also checked whether pairing the 24h-TTL cleanup DELETE with the critical email-storage INSERT in the same D1 batch() call (tools/e2e-inbox-worker/src/index.ts) risks losing a freshly delivered OTP if the batch partially fails. D1 batches run as an implicit transaction, so a failed DELETE would roll back the INSERT too — but the DELETE is a simple parameterized query with no realistic failure mode here, so this isn't a real risk.

Extended reasoning...

Examined the coupling between the opportunistic TTL-purge DELETE and the email-storage INSERT in the worker's email() handler, since both run in one env.DB.batch() call. D1 batches execute sequentially inside an implicit transaction, so a failure in either statement rolls back both — meaning a failed DELETE could theoretically prevent the just-arrived OTP email from being persisted. In practice the DELETE is a simple, deterministic received_ms < ? comparison against the primary table with no foreign keys or triggers, so there's no plausible runtime failure mode that would trigger a rollback and lose the email. This is a minor architectural coupling (cleanup logic mixed into the critical write path) but not a functional bug, so it doesn't warrant an inline comment on its own.

Comment thread tools/e2e-inbox-worker/test/env.d.ts Outdated
Comment thread tools/e2e-inbox-worker/package.json Outdated
- validate E2E_INBOX_API_URL as an absolute https URL (http only for loopback)
- bound WorkerInboxClient reads with an AbortController timeout
- exclude >24h messages from reads and add an hourly scheduled purge cron
- share the worker Env type between src and test declarations
- run the worker's tsc + vitest suite in CI on path changes
Comment thread .github/workflows/e2e-inbox-worker-ci.yml Fixed
Comment thread apps/deploy-web/tests/ui/fixture/inbox-api-url.schema.ts Outdated
Comment thread .github/workflows/e2e-inbox-worker-ci.yml Outdated
Comment thread .github/workflows/e2e-inbox-worker-ci.yml Outdated
Comment thread .github/workflows/e2e-inbox-worker-ci.yml Outdated
The validate job is not a required status check and the tool is fully
isolated (own lockfile, no monorepo deps), so re-running it against the
merge-queue commit adds no value. merge_group also ignores the paths
filter, so it ran npm ci + typecheck + test on every merge-queue entry
regardless of what changed. The pull_request paths filter already scopes
runs to changes under the tool and its workflow.
Comment thread tools/e2e-inbox-worker/src/index.ts Outdated
Comment thread .github/workflows/e2e-inbox-worker-ci.yml Outdated
An empty text/plain part decodes to whitespace (e.g. a lone newline),
which is truthy, so neither the nullish nor the logical-or operator
would drop it in favor of the html fallback. Gate on trimmed content so
an OTP that lives only in the html part is still captured for the code
regex. Covered by a multipart/alternative test with an empty text part.
akash.network carries production Google Workspace mail, and Cloudflare Email Routing
replaces a domain's MX records and cannot coexist with an external provider. The old
example invited enabling Email Routing on the apex and breaking real email. Docs now
require a dedicated domain (with a warning), and all examples and test fixtures use
placeholder domains.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and found no new bugs. Because it's a large (XL), security- and infrastructure-sensitive change — a new Cloudflare Worker with bearer-token auth backing a CI secret, plus GitHub Actions credential/permission changes — a human look is still worthwhile.

What was reviewed: the e2e-inbox-worker's email parsing/D1 storage/purge logic and its bearer-token-gated HTTP endpoint, the CI workflow's checkout/Node setup, and the Mailsac-to-worker migration across the e2e auth strategies (InboxClient/InboxCodeVerificationStrategy). Checked fetchMessageBody's unused email param — ruled out since message ids are globally unique (crypto.randomUUID()), so it's redundant, not a correctness bug. Checked the bearer-token comparison for timing-attack exposure — ruled out as impractical given it's a long random secret over TLS, not a security-critical auth boundary.

Extended reasoning...

Overview

This PR replaces the Mailsac-based e2e OTP inbox with a self-owned Cloudflare Email Worker + D1 (tools/e2e-inbox-worker), refactors the deploy-web e2e email-verification strategy from a Mailsac-specific implementation to a generic InboxClient/InboxCodeVerificationStrategy pair backed by a new WorkerInboxClient, updates CI workflows/actions to pass the new E2E_INBOX_* secrets/vars, and adds a dedicated CI workflow for the new tool. It also adds solid unit/integration test coverage for the new worker and client code.

Security risks

The main surface is the new worker's bearer-token-protected HTTP endpoint and the CI checkout for the new tool's workflow. Both were already flagged and addressed during review: the checkout now sets persist-credentials: false, and a non-constant-time token comparison was raised and ruled out as impractical for this use case (long random secret, TLS-only, test-only OTP data). The latest commit also removes akash.network as a live example domain from docs/fixtures, avoiding a documented footgun where enabling Cloudflare Email Routing on the real production domain would break its existing mail — a good defensive doc fix.

Level of scrutiny

This PR is labeled size: XL, introduces new production-adjacent infrastructure (a deployed Cloudflare Worker with its own secrets and one-time manual setup steps), and changes CI credential handling — all reasons to keep a human in the loop even with no outstanding bugs. It already went through multiple rounds of automated review with real findings (dead loopback entry, missing persist-credentials, merge_group gating, empty-text-part fallback) that were fixed, plus two nits (missing lint step, node-version-file resolution) the author explicitly declined with reasonable justification.

Other factors

Test coverage is strong: new unit specs for the URL schema, the inbox client, the verification strategy, and a Workers-runtime integration spec for the worker itself (auth, TTL purge, HTML/empty-text fallback, ordering). The remaining open nits are non-blocking and already have author responses recorded in the thread, so nothing appears to be outstanding without acknowledgment.

@github-actions github-actions Bot added size: L and removed size: XL labels Aug 10, 2026
Comment on lines +37 to +47
it("aborts the request when the worker does not respond within the timeout", async () => {
vi.useFakeTimers();
const { client } = setup({ hangsUntilAborted: true });

const pending = client.fetchMessages("probe@e2e.example.test");
const assertion = expect(pending).rejects.toThrow(/aborted/i);
await vi.advanceTimersByTimeAsync(10_000);

await assertion;
vi.useRealTimers();
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 The abort test at lines 37-47 enables vi.useFakeTimers() (line 38) but restores real timers with a manual vi.useRealTimers() on line 46, which sits after the awaited assertion (lines 42/45) — and the file's afterEach (lines 6-8) only calls vi.unstubAllGlobals(), never vi.useRealTimers(). If that assertion throws, fake timers leak into the rest of the file's tests. Move the restoration into afterEach (or a try/finally), matching the sibling inbox-code.strategy.unit.spec.ts, which does exactly this in its afterEach (lines 11-13).

Extended reasoning...

In worker-inbox.client.unit.spec.ts, the "aborts the request when the worker does not respond within the timeout" test enables fake timers at line 38 (vi.useFakeTimers()) and only restores real timers via an inline vi.useRealTimers() call on line 46. That line sits after the awaited assertion on lines 42/45 (expect(pending).rejects.toThrow(...) combined with await vi.advanceTimersByTimeAsync(10_000)). The file-level afterEach (lines 6-8) only calls vi.unstubAllGlobals() and never touches timer state.

This means restoration is entirely happy-path: it only runs if the test reaches the end without throwing. If the abort assertion on line 42 ever fails — for example if WorkerInboxClient.fetchMessages stops rejecting with an AbortError-shaped message, or the timeout wiring regresses — the test throws before line 46 executes, and fake timers (and the faked Date) stay active for the rest of the suite, including the two fetchMessageBody tests that run afterward.

This is a real gap relative to the sibling file added in the same PR, inbox-code.strategy.unit.spec.ts, which restores timers in afterEach(() => vi.useRealTimers()) (lines 11-13) precisely so cleanup happens regardless of test outcome. worker-inbox.client.unit.spec.ts does not follow that same pattern, so cleanup here is fragile in a way the rest of the PR already knows how to avoid.

To be precise about the actual blast radius (since this was raised in review): the two tests that run after the abort test are fetchMessageBody's "serves the body cached..." and "throws for a message id that was never listed". Neither depends on a real timer advancing — the first drives fetchMessages against a Response.json(...) mock that resolves on a microtask (not something vitest's fake timers intercept), and the second never calls fetch at all. So in the current test file, a timer leak would not actually cascade into hung/timed-out follow-on tests; both would likely still pass. The defect is real but narrower than "leaked timers make later tests hang" — it's a cleanup-only-on-success bug that happens to be currently harmless given what runs after it in this specific file.

Step-by-step proof of the leak itself:

  1. Test reaches line 38, calls vi.useFakeTimers() — fake Date/timers are now active for the runner.
  2. Line 41 kicks off client.fetchMessages(...) against a fetch mock that hangs until its abort signal fires.
  3. Line 43 advances fake time by 10_000 ms, which fires the AbortController timeout inside WorkerInboxClient.fetchMessages, rejecting the pending call.
  4. Line 42/45 asserts the rejection matches /aborted/i. If this assertion is wrong — e.g. a future refactor changes the rejection message or timing — the await assertion throws.
  5. Because the throw happens before line 46, vi.useRealTimers() never runs, and no afterEach picks up the slack either.
  6. Every subsequent test in this file now runs under stale fake-timer state until something else restores it.

Fix: move vi.useRealTimers() into the file's afterEach (alongside the existing vi.unstubAllGlobals()) or wrap the test body in try/finally, so restoration is outcome-independent — matching the pattern already used in inbox-code.strategy.unit.spec.ts.

This is test-only, only manifests once another assertion in this same test has already failed, and (per the analysis above) does not currently cascade into confusing failures elsewhere in this file — so it is a hygiene/consistency nit, not a blocking issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@claude fix it

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants