Repository navigation
fix(email): resolve the email service per tenant on late-tenant routes; report Resend usage - #368
Merged
important-new merged 6 commits intoOct 11, 2026
Conversation
…our send-only keys
Resend adapter changes, all derived from Resend's published API behaviour:
- Send results carry optional `usage` ({ dailyUsed, monthlyUsed }) parsed
from the x-resend-daily-quota / x-resend-monthly-quota response headers,
on success and on failure (a quota 429 carries them too). Absent or
malformed headers omit the field.
- A 429 `rate_limit_exceeded` is classified as `rate_limited` and the
`retry-after` header is exposed as `retryAfterSeconds`. Quota 429s keep
their existing classification. Callers that do not know the new kind
fall into the generic transient branch, so behaviour is unchanged.
- `probeApiKey` reports `reachable`. The Resend "Test connection" route
no longer says the key is valid when Resend could not be reached; it
answers 503 RESEND_UNREACHABLE. Save-time validation still ignores
reachability, so saving is never blocked by a provider outage.
- `listDomains` counts only verified domains and tells a send-only key
(which cannot list domains) apart from an account with none;
`countVerifiedDomains` now counts verified domains only.
- `validateCredentials` treats a 401 `restricted_api_key` as a valid
send-only key instead of a failure.
- Reading a header never throws when a fetch double has no headers object.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
… their tenant late
The DI middleware builds `services.email` from the tenant's email config
only when the tenant is already known at that point and the path is an
API path. Public, token and agent routes learn their tenant inside the
handler, after DI has run, so in multi-tenant mode the service they used
was the deployment default: platform sender, no Reply-To, platform brand,
the tenant's own provider and template overrides ignored, and no usage
metering. Replies to those messages reached the platform mailbox instead
of the company. Single-tenant mode was unaffected because the fixed tenant
is resolved for every request.
- Add `services.emailFor(tenantId)` and `services.platformEmail()`
(tenant-email-resolver.ts). `emailFor` reuses the service DI already
built for that tenant, otherwise loads the tenant's config once per
request. `platformEmail()` is the explicit deployment voice, for
sends that belong to no company (a global account's sign-in link).
- The eager, API-only config load in DI is unchanged on purpose: page
renders re-enter the middleware chain many times and must not pay for
a send they never make.
- Migrate the five affected send sites: public booking confirmation,
remote signing confirmation and receipts, repair-list share email, and
the two concierge flows (ConciergeService now takes a per-tenant
resolver instead of an instance).
- Because the resolved service now carries the new-company outbound
window that the empty-config service never had, a company's first
customer would stop getting the booking confirmation, and a signer the
signature confirmation, during the company's first 24 hours. Those two
answer something the recipient just did, so `emailFor(id, {
recipientInitiated: true })` returns the same tenant service without the
window. It is decided per call site, never per notification class: the
same class is also produced by automation rules for people who did
nothing, and those stay held.
- Tests drive the real middleware chain and routes in both modes with
only fetch stubbed, and assert sender, Reply-To, brand and that the
send is metered to the tenant. Multi-tenant mode failed before the
change; single-tenant mode passed.
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…d code `services.email` is only correct where the tenant is known before the DI middleware runs. Add a pre-commit gate that allows it in an explicit list of files (tenant-JWT routes, /api/portal/:tenant, /api/public/inspections /:id, the single-tenant password reset), each with the reason its tenant is known early, and fails on any other use. Everything else must call `emailFor(...)` or `platformEmail()`. The gate prints both the number of uses and the number of exempt uses on every run, reports a stale allow-list entry without failing, and its spec proves it fails on a violating fixture and passes on a clean one. Limit: it cannot see a `services.email` instance handed to a constructor inside di.ts, which is how the original concierge defect hid. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…ider is configured The sign-completion workflow's email step returned early whenever the deployment had no platform email key, so a deployment whose companies use only their own email provider never sent the signed-evidence message. Readiness is now decided from the tenant's resolved email service, and the step keeps its old no-op only when neither a platform key nor a tenant provider is available. The step moves to its own module. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…tion-and-provider-usage Combine the provider adapter changes (quota usage, rate-limit classification, send-only key handling) with the per-tenant email service resolution, so they ship as one milestone. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
…wer's timezone The consent block formats its date in the viewer's local timezone, and the fixture was midnight UTC. For every viewer west of Greenwich that is still the previous day, so the "Jun 12/13" assertion passed on UTC and east of it and failed everywhere else, including on a developer machine outside UTC. The fixture is now noon UTC, which is the 12th from UTC-11 to UTC+11 and the 13th beyond, both of which the assertion already accepts. The component is unchanged: showing the date in the viewer's timezone is the intended behaviour. Verified under UTC, Asia/Shanghai, America/Los_Angeles and Pacific/Auckland. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD
important-new
deleted the
fix/tenant-email-resolution-and-provider-usage
branch
October 11, 2026 12:29
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
Routes that learn their tenant only inside the handler (public booking, token-based signing, agent sign-in, repair-list share, concierge flows) were sending mail through the deployment-default email service. In multi-tenant mode that meant the platform sender, no Reply-To, platform branding, the tenant's own provider and template overrides ignored, and no usage metering. Replies landed in the platform mailbox instead of the company's.
This PR resolves the email service per tenant on those routes, adds a gate so the pattern cannot come back, and extends the Resend adapter to report quota usage and classify rate limiting.
What changes
Per-tenant email resolution
services.emailFor(tenantId, { recipientInitiated? })andservices.platformEmail()(server/lib/email/tenant-email-resolver.ts).emailForreuses the service the DI middleware already built for that tenant, otherwise loads the tenant's config once per request.platformEmail()is the explicit deployment voice for sends that belong to no company (e.g. a global account's sign-in link).ConciergeServicenow takes a per-tenant resolver).Outbound window, decided per call site
recipientInitiated: trueand skip it. The decision is per call site, never per notification class, because the same class is also produced by automation rules for people who did nothing.Gate
lint:tenant-email(pre-commit) allowsservices.emailonly in an explicit list of files whose tenant is known before DI runs, each with its reason. It prints use and exempt counts on every run, reports stale allow-list entries without failing, and its spec proves it fails on a violating fixture. Limitation: it cannot see an instance handed to a constructor insidedi.ts.Resend adapter
usage(dailyUsed,monthlyUsed) parsed from thex-resend-daily-quota/x-resend-monthly-quotaheaders, on success and failure; missing or malformed headers omit it.rate_limit_exceededis classifiedrate_limitedwithretryAfterSecondsfromretry-after. Callers that do not know the new kind fall into the generic transient branch.probeApiKeyreportsreachable; "Test connection" answers 503RESEND_UNREACHABLEinstead of claiming validity when Resend cannot be reached. Save-time validation still ignores reachability.restricted_api_keyis treated as a valid send-only key;listDomainscounts verified domains only and distinguishes a send-only key from an account with none.Impact by deployment mode
No migrations, no new bindings, no new environment variables.
Tests
Real middleware chain and routes in both modes with only
fetchstubbed; assertions cover sender, Reply-To, brand and per-tenant metering. The multi-tenant cases failed before the change; single-tenant passed.Window regression tests cover a first-day tenant (confirmation delivered) and automation-produced mail (still held).
Resend adapter: 34 new tests including header-less doubles; gate spec with violating and clean fixtures.
SmsConsentBlock.test.tsx: the fixture was midnight UTC, so the date assertion passed only on UTC and east of it. It is now noon UTC; the component is unchanged. Unrelated to the email work, included because it madetest:webfail on any developer machine outside UTC.Notes for reviewers
verifyfor it.package.jsonchange is one script (lint:tenant-email).🤖 Generated with Claude Code
https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD