Repository navigation
feat(email): human check for new companies, stepwise connection tests, quota handling - #370
Merged
important-new merged 17 commits intoOct 12, 2026
Conversation
… dry-run backfill for existing ones Without a configured Reply-To, a customer replying to an outbound message reaches the platform sender address instead of the inspector. The owner's email is the sensible default and stays editable in Settings. - New workspaces: when the owner account is created, write the owner's email to tenant_configs.reply_to. Both creation paths are covered: the provisioning command from a control plane and the standalone first-owner setup. The write happens only on the branch that creates the owner, so rotating a credential later cannot refill a field the operator cleared on purpose. Initialise-only: a value that is already set is never touched. Best-effort: a failure logs a warning and does not fail provisioning. - Existing workspaces: scripts/backfill-reply-to.mjs fills a NULL, empty or whitespace-only reply_to with the earliest-created owner's email. It is a dry run unless --apply is given, never overwrites a value, re-checks the blank condition inside each write statement so an edit between read and write wins, and prints "filled N / skipped M". A workspace with no owner, or whose owner email is blank, is skipped. The planning logic is a pure function with unit tests. - The script passes every statement to wrangler as a temp file: the wrapper forwards its arguments through a shell without quoting them, so a SQL string given with --command is split at the first space. Verified end to end against a throwaway local database with seven cases (NULL, blank, whitespace, already set, no owner, blank owner email, two owners): the right rows are filled, the earliest owner wins, a second dry run reports "filled 0". Not run against any real database. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…, throttle the automation flush
A provider rate limit or a used-up daily quota used to end an owed automation
email as a permanent failure, and a short rate limit in a request surfaced as a
generic 502. Both are transient and now treated as such.
- Request path: when the provider answers rate_limited with a retry-after of at
most two seconds, wait once and send again. A longer wait, or no hint, is
returned as a clear 429 EMAIL_RATE_LIMITED ("Email delivery is busy right
now. Please try again shortly.") instead of the generic 502. The sleep is
injected so tests do not wait.
- Quota window: a quota failure now carries an optional quotaWindow ("daily" or
"monthly"), taken from the provider's own error name. The failure kind is
unchanged and adapters that do not set the field behave as before.
- Automation flush: a rate limit keeps the row pending and moves send_at by the
provider's retry-after (60 seconds when it gives none, never less than one);
a daily quota keeps it pending until the next UTC midnight; a monthly quota
marks it failed with a reason a person can act on. A quota whose window is
unknown keeps the old terminal behaviour rather than being guessed into a
retry. This reuses the stay-pending-and-push-send_at mechanism of the
outbound window, so nothing new is needed to deliver the row later. The
report email's plain-text fallback is skipped after such a refusal, so the
same limit is not hit twice.
- Throttle: a flush batch starts at most five sends in any one second, using a
sliding window with an injected clock. It only adds wall time, not CPU time.
- The failure mapping moved out of services/email/base.ts into its own module so
base.ts shrinks instead of growing.
Known limits: the Outbox view shows a deferred row's "waiting" reason as
"Skipped" because it only recognises the outbound-window reason; teaching it the
new reasons needs new translations and is left for a follow-up.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
Adds POST /api/integrations/test-steps/{email,stripe}, one step per call
with prerequisites re-derived server-side.
Email (Resend): key valid (network failure reports unreachable, never
pass), key scope, sender domain verified (verified domains only; a
send-only key is told apart from an account with no domains), a real test
send, delivery receipt and quota headroom. The test send always goes to the
signed-in user's own address, looked up from the verified session; request
bodies cannot name a recipient. Other providers keep their single
credentials check as one step.
Stripe: key valid, charges/payouts/details_submitted, and webhook delivery
log. Payment-creating steps are intentionally not included.
Results are stored with a new nullable integration_test_results.step column
(migration 0001); history is now kept per (tenant, target, step) so a
multi-step run cannot evict earlier results.
server/index.ts file-size baseline 747 -> 749: the route table is a list that
grows by one mount per router (the new router could not live elsewhere; mounts
must stay in this file).
checkRateLimit gains an optional scope so the step endpoints share one
per-workspace limit.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
A correct TOTP code entered on /login re-rendered the challenge with
"Verification could not be completed": no session, no redirect. The API
accepted the code; the page could not use the answer. Three parts, each
sufficient to keep it broken:
1. POST /api/auth/login/2fa set the session cookie and returned only
{ redirect }. Unlike the password step, it had no x-token-relay branch,
and Login2faResponseSchema had no `token` field.
2. login.tsx called the 2FA endpoint without `x-token-relay: 1`, then read
data.token from the body.
3. The BFF client does not forward the API's Set-Cookie, so the session can
only be created from the JWT in the body (createSessionWithToken).
Fix: the 2FA handler mirrors the password step - `token` is in `data` only
when the caller sends `x-token-relay: 1`; browser callers still get just the
HttpOnly cookie. The schema declares the optional field, and the page sends
the header. CLAUDE.md and docs/reference/api.md now name the second-factor
step in the token-relay rule.
Tests, each shown red against the unfixed code first:
- tests/unit/auth/login-2fa-token-relay.spec.ts drives the real app: with
the header, data.token verifies against the deployment keyring; without
it, no token in the body and the cookie is still set.
- login.twofactor.server.test.ts no longer invents `{ data: { token } }`:
responses are built through Login2faResponseSchema and only carry the
token when the action sent the relay header.
- auth-two-factor.spec.ts signs in through the /login page with a TOTP code
and lands on /inspections authenticated, with a password-only page login
in the same run as a control.
Chrome: /login — password then TOTP code landed on /inspections signed in, light and dark
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dwrjSYhhqbh7Zd9iy2NTq
… Resend check On Email API keys the Resend "Test connection" button now runs a checklist instead of a single probe: key accepted, key permissions, sender domain verified, a real test email to the signed-in user, sending quota and delivery confirmation. The old single-shot button and its action intent are removed, so there is one control. Each step is one request through the route action (guarded submit, no client fetch); a failed step ends the run, and the last stored result per step is shown again after a reload. "Check delivery again" re-runs only the delivery step while the provider's report is still outstanding. Step rows are excluded from the one-line "Last tested" status, which keeps showing single-shot probes only. The new router is wired into the typed API client. English and Spanish strings added; three now-unused keys removed. Chrome: /settings/communication — one Test connection button on the Email API keys tab; with no key saved it runs the checklist in dark and light mode, the first step shows the failure reason and the later steps stay blank Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…ay rule
POST /api/auth/login shared AuthResponseSchema with /join and /setup, which
declares only { redirect }. The login handler also returns token (to a caller
sending x-token-relay: 1) and the second-factor challenge, so the declared
shape was wrong for login, and the page test could only hand-write a mock
the schema never described.
- LoginResponseSchema (login only): { redirect, token? } or
{ requires2fa, challengeToken }. AuthResponseSchema stays as is for join
and setup, which never put the JWT in the body.
- The page test builds every password-login mock through LoginResponseSchema
and carries token only when the call sent the relay header, like the
second-factor helper. Dropping the header from the page now fails it.
- login-2fa-token-relay.spec.ts covers the password step against the real
handler: token with the header (verified against the keyring), none
without, cookie set either way, bodies parsed with the declared schema.
- CLAUDE.md and docs/reference/api.md said setup and join use the relay.
They do not: their pages read the session JWT from the Set-Cookie header
of the in-process API response. Only login (both steps) relays the token
in the body, and only for x-token-relay: 1.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012dwrjSYhhqbh7Zd9iy2NTq
…st connection button Generalise the email step checklist into a shared StepChecklist and add a Stripe checklist (secret key accepted, account can take payments, webhook delivery verified). The Resend panel now has a single Test connection button that runs its checklist as well; the old single-shot Stripe test intent and its button are removed. The key check reports whether the key is live or test mode. Read-only: nothing is created in the Stripe account. Chrome: /settings/integrations — one Test connection button on the Stripe panel; with no key saved the first step fails and the later steps stay blank in dark and light mode Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…nt email in a new company's first 24 hours Inside the cooling window a send is now allowed when the request carries a Turnstile token that Cloudflare accepts. The web app asks for the challenge right before an action that emails a client (send report, agreements and reminders, payment requests, publish, client messages, resend, template test send), keeps the one-time token in a 60-second cookie, and the worker entry relays it to the API as a header. The API verifies it lazily, once per request, and only when a send would otherwise be refused. A request with no token (API or MCP client) is refused exactly as before with the same error code. Outside the window nothing is asked of anyone, and account-mechanics classes stay exempt. Automation sends that run later without a request still defer to the unlock time as before. File-size baselines raised by a few lines each (inspection-edit, inspector-portal, middleware/di): each adds one hook call and one awaited challenge line to a large route or middleware that already carried a cap; the shared logic lives in new small files. Chrome: /settings/communication/templates — sending a test email opens the confirm-you-are-a-person dialog, then the send completes with Test sent Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…l-resilience Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…ts and the human check The snapshot gains the two stepwise test-connection operations. The seam spec now expects the in-process API call to pass through the human-token relay, and the cooling-window banner copy again names what keeps working. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
The human-check wrapper re-fired the form with requestSubmit() from a microtask that, when no challenge was needed, ran inside the original submit dispatch. The browser ignores a nested requestSubmit there, so Send report and Publish report never posted outside a new company's window. The re-submit now waits one tick. Found by the role-aware-sending and subsystem-d e2e specs. Also: - real-D1 acceptance test clears tenant_configs before tenants (the owner row now creates a config row) - the stepwise test email is allow-listed in the notification-dispatch gate: it calls the provider adapter directly and only ever to the signed-in user - baselines refreshed with review: one by-id update in the deferral path, whose id comes from a row already read under tenant scope; the new test-step action file's two in-process calls (the settings routes shrank by the same amount) - schema doc regenerated for the new column; unused exports trimmed; Spanish strings aligned with the glossary Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
A person who passes the check while publishing a report in a new company's first 24 hours now vouches for the email rows that publish queued, including ones a rule delays: those are sent later with no request to carry a token. The publish route stamps the inspection's still-pending email rows with the time of the check (new nullable automation_logs.human_verified_at, migration 0002), and the flush path builds its email service with that proof for stamped rows, so they send at their due time instead of waiting for the window to end. Unstamped rows, and every request without a valid token, behave as before. Tenant-scoping baseline: two existing by-id updates in delivery.ts are re-keyed because the enclosing function the scanner attributes them to changed; the queries themselves are unchanged and take their id from a row read under scope. File-size baseline for the publish route raised by 4 lines: the import and the one call that stamps the queued rows; the logic lives in a new small file. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
Human check - A token header is honoured only on requests that reached the API through the web app's in-process relay. An API or MCP client that sets the header itself is ignored and refused as before (the relay marker is the same unforgeable request-scope key the memoisation already relies on). - Publishing vouches only for the report.published rows it queued, not for other pending mail on the same inspection. - A second challenge request replaces the open dialog and tells the first caller "not verified" instead of leaving it waiting; the re-submit flag cannot outlive its call; closing the dialog in a message composer no longer shows "send failed". Stepwise tests - send_test forgets the previous test send first, so a failed new send cannot leave an older message to be reported as delivered. - A 429 from the key probe, the domain listing or the send is retried once after a short pause; a sender address containing a line break is refused. - The checklist ignores a response it has already applied, so a re-run cannot consume the previous run's data. - The Reply-To backfill skips soft-deleted owners. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
… page action, and honest Outbox reasons - The public repair-share email (client portal and agent pages, which have no session context) is refused with the cooling-window code inside a new company's first 24 hours. The refusal now carries the Turnstile site key; the page action reports it, shows the human check, and sends again once passed. - A Turnstile token redeems once, so the verification result is now shared across every in-process API call of one page action instead of each call verifying (and spending) it separately. - Outbox rows held by the email provider's own limits (rate limit, daily quota) are shown with a plain "waiting" reason, and the monthly-quota failure says so, instead of "Skipped". The reasons are asserted against the server's constants. File-size baseline for RepairBuilderSection raised by 4 lines: the hook call, its render slot and the import; the logic lives in the shared verification module. chrome-allow: the new paths only appear for a company inside its first 24 hours on a SaaS deployment, which a standalone dev server cannot produce; the dialog is the already-checked shared one and the new hook and server refusal are covered by component and unit tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn9hNZdWoTSYXSc3ynthPu
…the file's own style - The challenge on the public share pages cannot loop: a refusal straight after a passed check is left to the page's own error instead of asking again. - A transient failure of the verification call is not cached as "not a person"; it gets one fresh attempt. A definite answer is still shared by every in-process call of one page action. - Publishing vouches for rows of the tenant's report.published and report.amended rules, matched on the rule rather than on a dedup-key prefix (which missed amended rows and rows carrying a caller-supplied event id). - A key probe that is still rate limited on the retry is reported as such, not as an accepted key. - The pending request is held in a ref rather than settled inside a state updater. The "cancelled" error moves to a neutral lib module so the message thread no longer imports the dialog component. - Style: imports sit in the file's existing groups, and the email service env interface and the tenant-less branch keep their one-member-per-line form. File-size baseline: build-email-service.ts now has an entry at 403. The file sat at 399; the typed site key on the env, the options parameter and the two braces the file's style keeps add four lines to a file that has no natural seam to split in this change. chrome-allow: the visible change is import order, a hook ref and the loop cap on a path that only appears for a SaaS company inside its first 24 hours, which a standalone dev server cannot produce; covered by component tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn9hNZdWoTSYXSc3ynthPu
- The verification call retries INSIDE the shared per-token computation, so the answer that is kept is the retried one and later or concurrent callers reuse it instead of redeeming the single-use token again. A throttled or failing siteverify (429, 5xx) now counts as "could not tell" and is retried, not read as "not a person". - When the page's check cannot be completed, the share-email refusal now carries an error message as well, so the loop cap ends in a visible failure instead of silence. The "sent again" flag expires after 30 seconds instead of staying armed if the re-send never ran. - A late callback from a replaced dialog can no longer settle its successor. - The stamp's comment now states its real scope: per inspection, not per report. chrome-allow: only reachable for a SaaS company inside its first 24 hours, which a standalone dev server cannot produce; the dialog logic is covered by component tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn9hNZdWoTSYXSc3ynthPu
- The siteverify call has a 5 second timeout, and the retry reuses one idempotency key, so a first request that reached Cloudflare and only lost its answer returns the same answer on the retry instead of "already redeemed". The two siteverify entry points now share one request builder. - A missing secret that makes the check unavailable is logged instead of looking like "not a person". - The share pages no longer show "could not send" behind the open dialog on the first refusal; the hook reports when the check was passed and the re-send was refused anyway, and only then does the page show its error. The re-send window is 90 seconds, and a late callback from a replaced dialog is ignored there too. chrome-allow: only reachable for a SaaS company inside its first 24 hours, which a standalone dev server cannot produce; the dialog and error logic is covered by component tests Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fn9hNZdWoTSYXSc3ynthPu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Outbound-email hardening and a sign-in fix, in a few independent commits.
Reply-To for new workspaces
tenant_configs.reply_tois initialised from the owner's email. Initialise-only, best-effort: a failure logsreply-to.init.failedand never blocks owner creation.scripts/backfill-reply-to.mjsfills existing workspaces. Dry-run by default;--applywrites,--remotetargets the remote D1. Statements re-check the value is still blank, so an interrupted run can be repeated.Rate-limit and quota resilience
withRateLimitRetry: a provider rate limit with retry-after <= 2s is absorbed with one inline wait; longer ones surface as 429EMAIL_RATE_LIMITED.quotaWindow(daily|monthly). The automation flush keeps a rate-limited row pending, defers a daily-quota row to the next UTC midnight, fails a monthly-quota row with a readable reason, and throttles sends to 5 per second.Step-by-step "Test connection" for BYO email and Stripe (includes a DB migration)
POST /api/integrations/test-steps/{email,stripe}run one step per request; prerequisites are re-derived server-side. Email: key accepted, key scope, sender domain verified, test email to the signed-in user, quota, delivery. Stripe (read-only): key accepted, account can take payments, webhook delivery seen. A send-only Resend key is a pass labelled send-only.0001adds a nullablestepcolumn tointegration_test_results(additive; apply before deploying).Confirm a person instead of refusing client email in a new company's first 24 hours
x-turnstile-token. The API verifies it lazily, once per request, only when a send would otherwise be refused.automation_logs.human_verified_at, migration0002), so they send at their due time instead of waiting out the window.Standalone two-factor sign-in through /login
fix/two-factor-login-relay: completes TOTP sign-in through the login page (token relay, response schema).Test plan
npm run test:unitandnpm run test:webrun on the combined branch; the failures it surfaced (OpenAPI snapshot, seam spec, banner copy, a timing-sensitive scan test) were fixed or re-run in isolation.🤖 Generated with Claude Code
https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD