Skip to content

fix(email): resolve the email service per tenant on late-tenant routes; report Resend usage - #368

Merged
important-new merged 6 commits into
InspectorHub:mainfrom
important-new:fix/tenant-email-resolution-and-provider-usage
Oct 11, 2026
Merged

important-new merged 6 commits into
InspectorHub:mainfrom
important-new:fix/tenant-email-resolution-and-provider-usage

Conversation

@important-new

@important-new important-new commented Oct 11, 2026 •

Copy link
Copy Markdown
Contributor

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

  • New services.emailFor(tenantId, { recipientInitiated? }) and services.platformEmail() (server/lib/email/tenant-email-resolver.ts). emailFor reuses 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).
  • The eager, API-only config load in DI is unchanged: page renders re-enter the middleware chain many times and must not pay for a send they never make.
  • Migrated 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).
  • The sign-completion workflow's email step now decides readiness from the resolved service, so a deployment where companies configure only their own provider still sends the signed-evidence message.

Outbound window, decided per call site

  • The resolved tenant service carries the new-company outbound window (SaaS mode, platform-funded sends only). Messages that answer something the recipient just did (booking confirmation, signature confirmation) pass recipientInitiated: true and 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) allows services.email only 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 inside di.ts.

Resend adapter

  • Send results carry optional usage (dailyUsed, monthlyUsed) parsed from the x-resend-daily-quota / x-resend-monthly-quota headers, on success and failure; missing or malformed headers omit it.
  • A 429 rate_limit_exceeded is classified rate_limited with retryAfterSeconds from retry-after. Callers that do not know the new kind fall into the generic transient branch.
  • probeApiKey reports reachable; "Test connection" answers 503 RESEND_UNREACHABLE instead of claiming validity when Resend cannot be reached. Save-time validation still ignores reachability.
  • A 401 restricted_api_key is treated as a valid send-only key; listDomains counts verified domains only and distinguishes a send-only key from an account with none.
  • Reading a header never throws when a fetch double has no headers object.

Impact by deployment mode

Behaviour change
Self-host / single-tenant None for sending. The fixed tenant was already resolved for every request, so the same service is used. The outbound window is SaaS-only and does not apply. Additions: the Resend usage fields and the clearer "Test connection" responses.
Multi-tenant, tenant with own provider (BYO) Public/token/agent routes now send through the tenant's provider with its sender, Reply-To, brand and template overrides, and are metered to the tenant. Previously these routes used the deployment default.
Multi-tenant, platform-funded tenant Same routes now use the tenant's sender identity, Reply-To and brand on the platform provider, and count against the tenant. Recipient-initiated confirmations are exempt from the new-company window.

No migrations, no new bindings, no new environment variables.

Tests

  • Real middleware chain and routes in both modes with only fetch stubbed; 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 made test:web fail on any developer machine outside UTC.

Notes for reviewers

  • Type-aware eslint was not run locally (memory ceiling on the author's machine); relying on CI verify for it.
  • package.json change is one script (lint:tenant-email).

🤖 Generated with Claude Code

https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD

important-new and others added 6 commits October 11, 2026 03:20
…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
important-new merged commit f8c86b4 into InspectorHub:main Oct 11, 2026
20 checks passed
@important-new
important-new deleted the fix/tenant-email-resolution-and-provider-usage branch October 11, 2026 12:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant