From c316f64da8cbe9989addbd4c1568e519773d488d Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:20:52 -0400 Subject: [PATCH 1/5] feat(email): report provider quota usage, classify rate limiting, honour 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 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- server/api/integrations.ts | 21 +- server/lib/email/provider.ts | 39 ++- server/lib/email/providers/resend.ts | 124 +++++-- .../unit/email/email-provider-resend.spec.ts | 4 +- .../unit/email/resend-usage-and-probe.spec.ts | 312 ++++++++++++++++++ 5 files changed, 467 insertions(+), 33 deletions(-) create mode 100644 tests/unit/email/resend-usage-and-probe.spec.ts diff --git a/server/api/integrations.ts b/server/api/integrations.ts index 49bc9bb61..5db872b16 100644 --- a/server/api/integrations.ts +++ b/server/api/integrations.ts @@ -217,13 +217,28 @@ const integrationsRoutes = createApiRouter() const { ResendProvider } = await import('../lib/email/providers/resend'); const provider = new ResendProvider({ apiKey: key }); const probe = await provider.probeApiKey(); + // No HTTP response at all: the key was never checked, so never report it valid. + if (!probe.reachable) { + await logTest(c.env, tenantId, uid, 'email', false, 'Could not reach Resend.', 'resend'); + return c.json({ success: false as const, error: { code: 'RESEND_UNREACHABLE', message: 'Could not reach Resend.' } }, 503); + } if (!probe.valid) { await logTest(c.env, tenantId, uid, 'email', false, 'Resend rejected the stored API key.', 'resend'); return c.json({ success: false as const, error: { code: 'RESEND_KEY_INVALID', message: 'Resend rejected the stored API key.' } }, 502); } - const domains = await provider.countVerifiedDomains(); - await logTest(c.env, tenantId, uid, 'email', true, `Resend key valid${domains > 0 ? ` · ${domains} verified domain${domains === 1 ? '' : 's'}` : ''}.`, 'resend'); - return c.json({ success: true as const, data: { domains } }, 200); + const listing = await provider.listDomains(); + if (!listing.ok && listing.reason === 'restricted') { + await logTest(c.env, tenantId, uid, 'email', true, 'Resend key valid (send-only key; sender domains cannot be checked).', 'resend'); + return c.json({ success: true as const, data: { domains: 0 } }, 200); + } + const verified = listing.ok ? listing.verified : 0; + const unverified = listing.ok ? listing.total - listing.verified : 0; + const verifiedPart = verified > 0 + ? ` · ${verified} verified domain${verified === 1 ? '' : 's'}` + : unverified > 0 ? ' · no verified domains' : ''; + const unverifiedPart = unverified > 0 ? ` (${unverified} not yet verified)` : ''; + await logTest(c.env, tenantId, uid, 'email', true, `Resend key valid${verifiedPart}${unverifiedPart}.`, 'resend'); + return c.json({ success: true as const, data: { domains: verified } }, 200); }) .openapi(emailValidateRoute, async (c) => { const { provider } = c.req.valid('json'); diff --git a/server/lib/email/provider.ts b/server/lib/email/provider.ts index fac8334c6..25800c69c 100644 --- a/server/lib/email/provider.ts +++ b/server/lib/email/provider.ts @@ -68,8 +68,11 @@ export type NormalizedEmailEvent = { /** * Structured failure kinds returned by `sendEmail`. * - * `transient` — provider-side fault (5xx, rate-limit, lock). Safe to - * retry after a delay; no operator action required now. + * `transient` — provider-side fault (5xx, lock, network). Safe to retry + * after a delay; no operator action required now. + * `rate_limited` — the provider's request-rate limit was hit (HTTP 429, not a + * quota). Retry after backing off; `retryAfterSeconds` carries + * the provider's `retry-after` hint when it sent one. * `suppressed` — Resend (or another provider) rejected the address because * it is on their suppression list. The send will keep failing * until the block clears; write it to `email_suppressions` @@ -79,24 +82,44 @@ export type NormalizedEmailEvent = { * `sender_domain` — the `from` domain is unverified or the key is limited to * test sends. Operator must verify the domain in Resend. */ -export type EmailFailureKind = 'transient' | 'suppressed' | 'quota_exceeded' | 'sender_domain'; +export type EmailFailureKind = + | 'transient' + | 'rate_limited' + | 'suppressed' + | 'quota_exceeded' + | 'sender_domain'; + +/** + * Sending-quota counters a provider reported alongside a response. Values are + * emails USED so far in the window (not remaining). Each field is present only + * when the provider sent a valid reading for it. + */ +export interface EmailSendUsage { + dailyUsed?: number; + monthlyUsed?: number; +} export interface EmailProvider { /** * Send a transactional email. * - * Returns `{ ok: true; id? }` on success. - * Returns `{ ok: false; error; kind }` on failure — implementations MUST NOT + * Returns `{ ok: true; id?; usage? }` on success. + * Returns `{ ok: false; error; kind; retryAfterSeconds?; usage? }` on failure — implementations MUST NOT * throw. `kind` lets callers branch on WHY delivery failed rather than * treating all failures identically. */ - sendEmail(args: EmailSendArgs): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }>; + sendEmail(args: EmailSendArgs): Promise< + | { ok: true; id?: string; usage?: EmailSendUsage } + | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; usage?: EmailSendUsage } + >; /** * Optional lightweight credential check (e.g. a GET /domains call). - * Used by Settings "validate on save" flows — Task 6. + * Used by Settings "validate on save" flows — Task 6. `restricted: true` + * means the key is valid but scoped to sending only, so account-level + * endpoints (e.g. domain listing) are not available to it. */ - validateCredentials?(): Promise<{ ok: true } | { ok: false; error: string }>; + validateCredentials?(): Promise<{ ok: true; restricted?: true } | { ok: false; error: string }>; /** * Verify an inbound webhook signature using the provider's scheme. Reads only diff --git a/server/lib/email/providers/resend.ts b/server/lib/email/providers/resend.ts index 5ebcbe792..4b41adb3a 100644 --- a/server/lib/email/providers/resend.ts +++ b/server/lib/email/providers/resend.ts @@ -1,5 +1,5 @@ import { logger } from '../../logger'; -import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailWebhookContext, NormalizedEmailEvent } from '../provider'; +import type { EmailFailureKind, EmailProvider, EmailSendArgs, EmailSendUsage, EmailWebhookContext, NormalizedEmailEvent } from '../provider'; import { base64ToBytes, bytesToBase64, @@ -10,7 +10,49 @@ import { } from '../webhook-crypto'; /** - * Classify a Resend non-2xx response into one of the four `EmailFailureKind` + * A real `Response` always has headers; hand-rolled fetch doubles often do not. + * Reading a header must never turn such a response into a thrown TypeError. + */ +function headerValue(headers: Headers | undefined, name: string): string | null { + return typeof headers?.get === 'function' ? headers.get(name) : null; +} + +/** Strict non-negative integer parse; anything else (absent, garbage, negative, fractional) is undefined. */ +function parseCount(raw: string | null): number | undefined { + if (raw === null || !/^[0-9]+$/.test(raw.trim())) return undefined; + const n = Number(raw.trim()); + return Number.isSafeInteger(n) ? n : undefined; +} + +/** + * Read Resend's quota headers: `x-resend-daily-quota` (emails used today; only + * sent for accounts with a daily cap) and `x-resend-monthly-quota` (emails used + * this month). Returns undefined when neither carries a valid reading. + */ +function readUsage(headers: Headers | undefined): EmailSendUsage | undefined { + const dailyUsed = parseCount(headerValue(headers, 'x-resend-daily-quota')); + const monthlyUsed = parseCount(headerValue(headers, 'x-resend-monthly-quota')); + if (dailyUsed === undefined && monthlyUsed === undefined) return undefined; + return { + ...(dailyUsed !== undefined ? { dailyUsed } : {}), + ...(monthlyUsed !== undefined ? { monthlyUsed } : {}), + }; +} + +/** Parse `retry-after` as integer seconds in [0, 86400]; anything else is undefined. */ +function readRetryAfterSeconds(headers: Headers | undefined): number | undefined { + const n = parseCount(headerValue(headers, 'retry-after')); + return n !== undefined && n <= 86400 ? n : undefined; +} + +/** Name field of a Resend JSON error body, or undefined when the body is absent/unparseable. */ +async function readErrorName(res: Response): Promise { + const body = (await res.json().catch(() => null)) as { name?: unknown } | null; + return typeof body?.name === 'string' ? body.name : undefined; +} + +/** + * Classify a Resend non-2xx response into one of the `EmailFailureKind` * buckets. The mapping is derived from Resend's published error reference * (resend.com/docs/api-reference/errors). * @@ -21,9 +63,10 @@ import { * 2. Sender-domain — any other 403 validation_error (unverified domain, test * key restricted to own address, domain already registered on another team). * 3. Quota — 429 daily_quota_exceeded | monthly_quota_exceeded. - * 4. Transient — everything else: rate-limit, 5xx, lock/conflict, network. + * 4. Rate-limited — 429 rate_limit_exceeded (request rate, not a quota). + * 5. Transient — everything else: 5xx, lock/conflict, network. * - * Any Resend error not covered by (1)–(3) is treated as transient because + * Any Resend error not covered by (1)–(4) is treated as transient because * retrying is the safest default — the operator can always check the logs. */ export function classifyResendError( @@ -41,8 +84,8 @@ export function classifyResendError( if (name === 'daily_quota_exceeded' || name === 'monthly_quota_exceeded') { return 'quota_exceeded'; } - // rate_limit_exceeded → transient (retry after backing off) - return 'transient'; + // rate_limit_exceeded → back off and retry; any other 429 stays transient + return name === 'rate_limit_exceeded' ? 'rate_limited' : 'transient'; } // 4xx config errors (suspended key, missing key, etc.) are not recoverable // by the recipient changing anything — treat as transient so the operator @@ -60,7 +103,10 @@ export class ResendProvider implements EmailProvider { async sendEmail( args: EmailSendArgs, - ): Promise<{ ok: true; id?: string } | { ok: false; error: string; kind: EmailFailureKind }> { + ): Promise< + | { ok: true; id?: string; usage?: EmailSendUsage } + | { ok: false; error: string; kind: EmailFailureKind; retryAfterSeconds?: number; usage?: EmailSendUsage } + > { const payload: Record = { from: args.from, to: args.to, @@ -87,10 +133,12 @@ export class ResendProvider implements EmailProvider { return { ok: false, error: message, kind: 'transient' }; } + const usage = readUsage(res.headers); + if (res.ok) { let json: { id?: string } | null = null; try { json = (await res.json()) as { id?: string }; } catch { /* empty body */ } - return { ok: true, ...(json?.id ? { id: json.id } : {}) }; + return { ok: true, ...(json?.id ? { id: json.id } : {}), ...(usage ? { usage } : {}) }; } // Non-2xx — parse the body once, then classify. @@ -106,10 +154,15 @@ export class ResendProvider implements EmailProvider { const kind = classifyResendError(res.status, errName, errMsg); logger.error('[email] ResendProvider delivery failed', { status: res.status, kind, error: errMsg }); - return { ok: false, error: errMsg, kind }; + const retryAfterSeconds = kind === 'rate_limited' ? readRetryAfterSeconds(res.headers) : undefined; + return { + ok: false, error: errMsg, kind, + ...(retryAfterSeconds !== undefined ? { retryAfterSeconds } : {}), + ...(usage ? { usage } : {}), + }; } - async validateCredentials(): Promise<{ ok: true } | { ok: false; error: string }> { + async validateCredentials(): Promise<{ ok: true; restricted?: true } | { ok: false; error: string }> { let res: Response; try { res = await fetch('https://api.resend.com/domains', { @@ -120,6 +173,10 @@ export class ResendProvider implements EmailProvider { return { ok: false, error: message }; } if (res.ok) return { ok: true }; + // A send-only key is valid, but Resend refuses it on non-send endpoints. + if (res.status === 401 && (await readErrorName(res)) === 'restricted_api_key') { + return { ok: true, restricted: true }; + } return { ok: false, error: `Resend ${res.status}` }; } @@ -129,8 +186,12 @@ export class ResendProvider implements EmailProvider { * restricted keys that 401 on GET /domains) → 422. No email is ever sent. * Lives on the provider so route handlers never hand-roll Resend URLs * (`lint:provider-helpers`). + * + * `reachable` is true when an HTTP response was received. On a network + * failure `valid` stays true (a save is never blocked on provider uptime) + * and `reachable` is false; callers that report a verdict must check it. */ - async probeApiKey(): Promise<{ valid: boolean; status: number | null }> { + async probeApiKey(): Promise<{ valid: boolean; status: number | null; reachable: boolean }> { let res: Response | null; try { res = await fetch('https://api.resend.com/emails', { @@ -142,25 +203,48 @@ export class ResendProvider implements EmailProvider { body: '{}', }); } catch { - return { valid: true, status: null }; // network/5xx → don't block save on Resend uptime + return { valid: true, status: null, reachable: false }; // network failure → don't block save on Resend uptime } - if (res.status === 401 || res.status === 403) return { valid: false, status: res.status }; - return { valid: true, status: res.status }; + if (res.status === 401 || res.status === 403) return { valid: false, status: res.status, reachable: true }; + return { valid: true, status: res.status, reachable: true }; } - /** Count of verified domains visible to this key (0 when the key cannot list). */ - async countVerifiedDomains(): Promise { + /** + * List the sending domains visible to this key (GET /domains) and count the + * verified ones. A send-only key is rejected by Resend with 401 + * `restricted_api_key` and reports `reason: 'restricted'`. + */ + async listDomains(): Promise< + | { ok: true; total: number; verified: number } + | { ok: false; reason: 'restricted' | 'unreachable' | 'error'; status: number | null } + > { let res: Response; try { res = await fetch('https://api.resend.com/domains', { headers: { 'Authorization': `Bearer ${this.creds.apiKey}` }, }); } catch { - return 0; + return { ok: false, reason: 'unreachable', status: null }; + } + if (!res.ok) { + if (res.status === 401 && (await readErrorName(res)) === 'restricted_api_key') { + return { ok: false, reason: 'restricted', status: res.status }; + } + return { ok: false, reason: 'error', status: res.status }; } - if (!res.ok) return 0; - const body = (await res.json().catch(() => null)) as { data?: unknown[] } | null; - return Array.isArray(body?.data) ? body.data.length : 0; + const body = (await res.json().catch(() => null)) as { data?: unknown } | null; + const data: unknown[] = Array.isArray(body?.data) ? body.data : []; + const verified = data.filter((d) => { + const status = (d as { status?: unknown } | null)?.status; + return typeof status === 'string' && status.toLowerCase() === 'verified'; + }).length; + return { ok: true, total: data.length, verified }; + } + + /** Count of verified domains visible to this key (0 when the key cannot list). */ + async countVerifiedDomains(): Promise { + const result = await this.listDomains(); + return result.ok ? result.verified : 0; } /** diff --git a/tests/unit/email/email-provider-resend.spec.ts b/tests/unit/email/email-provider-resend.spec.ts index 83a18b407..7b7db5186 100644 --- a/tests/unit/email/email-provider-resend.spec.ts +++ b/tests/unit/email/email-provider-resend.spec.ts @@ -81,8 +81,8 @@ describe('classifyResendError', () => { expect(classifyResendError(429, 'monthly_quota_exceeded', 'You have exceeded your monthly email sending quota.')).toBe('quota_exceeded'); }); - it('classifies 429 rate_limit_exceeded as transient', () => { - expect(classifyResendError(429, 'rate_limit_exceeded', 'Too many requests.')).toBe('transient'); + it('classifies 429 rate_limit_exceeded as rate_limited', () => { + expect(classifyResendError(429, 'rate_limit_exceeded', 'Too many requests.')).toBe('rate_limited'); }); it('classifies 500 application_error as transient', () => { diff --git a/tests/unit/email/resend-usage-and-probe.spec.ts b/tests/unit/email/resend-usage-and-probe.spec.ts new file mode 100644 index 000000000..00ad08b46 --- /dev/null +++ b/tests/unit/email/resend-usage-and-probe.spec.ts @@ -0,0 +1,312 @@ +/** + * Resend adapter: usage headers, rate-limit classification, reachability-aware + * probes, domain listing, restricted (send-only) keys, and the /resend/test route. + * All network access is stubbed. + */ +import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; +import { OpenAPIHono } from '@hono/zod-openapi'; +import { ResendProvider, classifyResendError } from '../../../server/lib/email/providers/resend'; +import type { HonoConfig } from '../../../server/types/hono'; + +const recordMock = vi.hoisted(() => vi.fn(async (..._args: unknown[]) => undefined)); +vi.mock('../../../server/lib/integration-test-results', async (orig) => { + const actual = await orig(); + return { ...actual, recordIntegrationTest: recordMock }; +}); + +import integrationsRoutes from '../../../server/api/integrations'; + +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); + +const SEND_ARGS = { from: 'a@x.com', to: 'b@y.com', subject: 'Hi', html: '

hi

' }; +const json = (body: unknown, status: number, headers: Record = {}) => + new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', ...headers } }); +const send = () => new ResendProvider({ apiKey: 're_test' }).sendEmail(SEND_ARGS); +const networkDown = () => vi.fn(async () => { throw new TypeError('fetch failed'); }); + +describe('ResendProvider.sendEmail usage headers', () => { + it('reports both counters on success', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ id: 'e1' }, 200, { + 'x-resend-daily-quota': '12', 'x-resend-monthly-quota': '340', + }))); + expect(await send()).toEqual({ ok: true, id: 'e1', usage: { dailyUsed: 12, monthlyUsed: 340 } }); + }); + + it('reports only the monthly counter when the daily header is absent', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ id: 'e1' }, 200, { 'x-resend-monthly-quota': '7' }))); + expect(await send()).toEqual({ ok: true, id: 'e1', usage: { monthlyUsed: 7 } }); + }); + + it('omits the usage key entirely when neither header is present', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ id: 'e1' }, 200))); + const res = await send(); + expect(res).toEqual({ ok: true, id: 'e1' }); + expect('usage' in res).toBe(false); + }); + + it('ignores garbage, negative and fractional values', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ id: 'e1' }, 200, { + 'x-resend-daily-quota': 'abc', 'x-resend-monthly-quota': '-5', + }))); + const res = await send(); + expect('usage' in res).toBe(false); + vi.stubGlobal('fetch', vi.fn(async () => json({ id: 'e1' }, 200, { + 'x-resend-daily-quota': '1.5', 'x-resend-monthly-quota': '3', + }))); + expect(await send()).toEqual({ ok: true, id: 'e1', usage: { monthlyUsed: 3 } }); + }); + + it('attaches usage to a quota-exceeded 429 failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json( + { name: 'daily_quota_exceeded', message: 'Daily quota exceeded.' }, 429, + { 'x-resend-daily-quota': '100', 'x-resend-monthly-quota': '2900' }, + ))); + expect(await send()).toEqual({ + ok: false, error: 'Daily quota exceeded.', kind: 'quota_exceeded', + usage: { dailyUsed: 100, monthlyUsed: 2900 }, + }); + }); +}); + +describe('Resend rate-limit classification', () => { + it('classifies 429 rate_limit_exceeded as rate_limited', () => { + expect(classifyResendError(429, 'rate_limit_exceeded', 'Too many requests.')).toBe('rate_limited'); + }); + + it('keeps quota 429s as quota_exceeded', () => { + expect(classifyResendError(429, 'daily_quota_exceeded', 'x')).toBe('quota_exceeded'); + expect(classifyResendError(429, 'monthly_quota_exceeded', 'x')).toBe('quota_exceeded'); + }); + + it('surfaces retry-after seconds on a rate-limited failure', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json( + { name: 'rate_limit_exceeded', message: 'Too many requests.' }, 429, { 'retry-after': '2' }, + ))); + expect(await send()).toEqual({ + ok: false, error: 'Too many requests.', kind: 'rate_limited', retryAfterSeconds: 2, + }); + }); + + it('omits retryAfterSeconds when the header is absent', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'rate_limit_exceeded', message: 'Slow down.' }, 429))); + const res = await send(); + expect(res).toEqual({ ok: false, error: 'Slow down.', kind: 'rate_limited' }); + expect('retryAfterSeconds' in res).toBe(false); + }); + + it('ignores garbage, negative and oversized retry-after values', async () => { + for (const bad of ['soon', '-3', '86401', '1.5']) { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'rate_limit_exceeded', message: 'm' }, 429, { 'retry-after': bad }))); + const res = await send(); + expect('retryAfterSeconds' in res).toBe(false); + } + }); +}); + +describe('ResendProvider.probeApiKey', () => { + const probe = () => new ResendProvider({ apiKey: 're_test' }).probeApiKey(); + + it('reports reachable with the HTTP status when a response arrives', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({}, 422))); + expect(await probe()).toEqual({ valid: true, status: 422, reachable: true }); + vi.stubGlobal('fetch', vi.fn(async () => json({}, 401))); + expect(await probe()).toEqual({ valid: false, status: 401, reachable: true }); + }); + + it('flags a network failure as unreachable but keeps valid:true for save flows', async () => { + vi.stubGlobal('fetch', networkDown()); + expect(await probe()).toEqual({ valid: true, status: null, reachable: false }); + }); +}); + +describe('ResendProvider.listDomains', () => { + const list = () => new ResendProvider({ apiKey: 're_test' }).listDomains(); + + it('counts only verified domains among mixed statuses (case-insensitive)', async () => { + const fetchMock = vi.fn(async (_url: string) => json({ data: [ + { name: 'a.com', status: 'verified' }, + { name: 'b.com', status: 'Verified' }, + { name: 'c.com', status: 'pending' }, + { name: 'd.com', status: 'failed' }, + ] }, 200)); + vi.stubGlobal('fetch', fetchMock); + expect(await list()).toEqual({ ok: true, total: 4, verified: 2 }); + expect(fetchMock.mock.calls[0]?.[0]).toBe('https://api.resend.com/domains'); + }); + + it('returns zero totals for an empty account', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }, 200))); + expect(await list()).toEqual({ ok: true, total: 0, verified: 0 }); + }); + + it('reports a send-only key as restricted', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'restricted_api_key', message: 'no' }, 401))); + expect(await list()).toEqual({ ok: false, reason: 'restricted', status: 401 }); + }); + + it('reports a plain 401 as an error, not restricted', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'invalid_api_key', message: 'no' }, 401))); + expect(await list()).toEqual({ ok: false, reason: 'error', status: 401 }); + }); + + it('reports network failure as unreachable', async () => { + vi.stubGlobal('fetch', networkDown()); + expect(await list()).toEqual({ ok: false, reason: 'unreachable', status: null }); + }); + + it('reports a 500 as an error', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({}, 500))); + expect(await list()).toEqual({ ok: false, reason: 'error', status: 500 }); + }); +}); + +describe('ResendProvider.countVerifiedDomains (compat)', () => { + const count = () => new ResendProvider({ apiKey: 're_test' }).countVerifiedDomains(); + + it('returns the verified count', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [ + { name: 'a.com', status: 'verified' }, { name: 'b.com', status: 'pending' }, + ] }, 200))); + expect(await count()).toBe(1); + }); + + it('returns 0 for restricted, unreachable and error outcomes', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'restricted_api_key' }, 401))); + expect(await count()).toBe(0); + vi.stubGlobal('fetch', networkDown()); + expect(await count()).toBe(0); + vi.stubGlobal('fetch', vi.fn(async () => json({}, 500))); + expect(await count()).toBe(0); + }); +}); + +describe('ResendProvider.validateCredentials', () => { + const validate = () => new ResendProvider({ apiKey: 're_test' }).validateCredentials(); + + it('accepts a send-only key as ok with restricted:true', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'restricted_api_key', message: 'no' }, 401))); + expect(await validate()).toEqual({ ok: true, restricted: true }); + }); + + it('keeps a plain 401 invalid with the same message', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ name: 'invalid_api_key' }, 401))); + expect(await validate()).toEqual({ ok: false, error: 'Resend 401' }); + }); + + it('keeps a 401 with an unparseable body invalid', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('nope', { status: 401 }))); + expect(await validate()).toEqual({ ok: false, error: 'Resend 401' }); + }); + + it('keeps a network error invalid', async () => { + vi.stubGlobal('fetch', networkDown()); + expect(await validate()).toEqual({ ok: false, error: 'fetch failed' }); + }); + + it('keeps a plain success as { ok: true }', async () => { + vi.stubGlobal('fetch', vi.fn(async () => json({ data: [] }, 200))); + expect(await validate()).toEqual({ ok: true }); + }); +}); + +describe('POST /api/integrations/resend/test', () => { + beforeEach(() => { recordMock.mockClear(); }); + + function call(env: Record = {}) { + const app = new OpenAPIHono(); + app.use('*', async (c, next) => { + c.set('userRole', 'owner'); + c.set('tenantId', 'tenant-resend-test'); + await next(); + }); + app.route('/api/integrations', integrationsRoutes); + return app.request('/api/integrations/resend/test', { method: 'POST' }, + { JWT_SECRET: 'x'.repeat(32), DB: {}, ...env }); + } + const recorded = () => recordMock.mock.calls.at(-1)?.[1] as { ok: boolean; detail: string } | undefined; + + /** Route fetch by URL: POST /emails is the probe, GET /domains the listing. */ + function stubResend(probe: () => Response | Promise, domains: () => Response | Promise) { + vi.stubGlobal('fetch', vi.fn(async (url: string) => (String(url).endsWith('/domains') ? domains() : probe()))); + } + + it('503 RESEND_NOT_CONFIGURED when no key is set', async () => { + const res = await call({}); + expect(res.status).toBe(503); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe('RESEND_NOT_CONFIGURED'); + }); + + it('502 RESEND_KEY_INVALID when Resend rejects the key', async () => { + stubResend(() => json({}, 401), () => json({}, 401)); + const res = await call({ RESEND_API_KEY: 're_bad' }); + expect(res.status).toBe(502); + expect(((await res.json()) as { error: { code: string } }).error.code).toBe('RESEND_KEY_INVALID'); + expect(recorded()?.ok).toBe(false); + }); + + it('503 RESEND_UNREACHABLE and a failed record when Resend cannot be reached', async () => { + vi.stubGlobal('fetch', networkDown()); + const res = await call({ RESEND_API_KEY: 're_x' }); + expect(res.status).toBe(503); + const body = (await res.json()) as { success: boolean; error: { code: string; message: string } }; + expect(body.error).toEqual({ code: 'RESEND_UNREACHABLE', message: 'Could not reach Resend.' }); + expect(recorded()).toMatchObject({ ok: false, detail: 'Could not reach Resend.' }); + expect(recorded()?.detail).not.toMatch(/valid/i); + }); + + it('treats a send-only key as success with domains: 0', async () => { + stubResend(() => json({}, 422), () => json({ name: 'restricted_api_key', message: 'no' }, 401)); + const res = await call({ RESEND_API_KEY: 're_sendonly' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { domains: 0 } }); + expect(recorded()).toMatchObject({ + ok: true, detail: 'Resend key valid (send-only key; sender domains cannot be checked).', + }); + }); + + it('counts verified domains only and mentions the unverified ones', async () => { + stubResend(() => json({}, 422), () => json({ data: [ + { name: 'a.com', status: 'verified' }, { name: 'b.com', status: 'verified' }, + { name: 'c.com', status: 'pending' }, + ] }, 200)); + const res = await call({ RESEND_API_KEY: 're_full' }); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ success: true, data: { domains: 2 } }); + expect(recorded()?.ok).toBe(true); + expect(recorded()?.detail).toBe('Resend key valid · 2 verified domains (1 not yet verified).'); + }); + + it('reports a single verified domain with no unverified remainder', async () => { + stubResend(() => json({}, 422), () => json({ data: [{ name: 'a.com', status: 'verified' }] }, 200)); + const res = await call({ RESEND_API_KEY: 're_full' }); + expect(res.status).toBe(200); + expect(recorded()?.detail).toBe('Resend key valid · 1 verified domain.'); + }); + + it('reports a key with no verified domains without a count claim', async () => { + stubResend(() => json({}, 422), () => json({ data: [{ name: 'c.com', status: 'pending' }] }, 200)); + const res = await call({ RESEND_API_KEY: 're_full' }); + expect(await res.json()).toEqual({ success: true, data: { domains: 0 } }); + expect(recorded()?.detail).toBe('Resend key valid · no verified domains (1 not yet verified).'); + }); +}); + +describe('ResendProvider.sendEmail with a response that has no headers object', () => { + // Real fetch responses always carry headers; hand-rolled test doubles for fetch often + // do not. Reading usage must never turn such a response into a thrown TypeError. + const bare = (ok: boolean, status: number, body: unknown) => + vi.fn(async () => ({ ok, status, json: async () => body, text: async () => '' })); + + it('still reports success and omits usage', async () => { + vi.stubGlobal('fetch', bare(true, 200, { id: 'e1' })); + expect(await send()).toEqual({ ok: true, id: 'e1' }); + }); + + it('still classifies a failure and omits usage and retry-after', async () => { + vi.stubGlobal('fetch', bare(false, 429, { name: 'rate_limit_exceeded', message: 'slow down' })); + const res = await send(); + expect(res).toMatchObject({ ok: false, kind: 'rate_limited' }); + expect('usage' in res).toBe(false); + expect('retryAfterSeconds' in res).toBe(false); + }); +}); From 450a95833680aaa401a6a616d886140c32ec36ee Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:21:10 -0400 Subject: [PATCH 2/5] fix(email): resolve the email service per tenant on routes that learn 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 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- server/api/agent/login.ts | 3 +- server/api/agent/magic-login.ts | 3 +- server/api/bookings/agreement.ts | 2 +- server/api/inspections/agreement-sign.ts | 1 + server/api/repair-builder.ts | 6 +- server/lib/email/build-email-service.ts | 6 +- server/lib/email/outbound-cooling-window.ts | 14 +- server/lib/email/tenant-email-resolver.ts | 99 +++++++ server/lib/middleware/di.ts | 40 +-- server/lib/sign-effects.ts | 13 +- .../services/booking/booking-confirmation.ts | 6 +- server/services/concierge.service.ts | 16 +- server/types/app-services.ts | 11 + tests/unit/agent/agent-login.spec.ts | 4 +- tests/unit/agent/magic-login.spec.ts | 3 +- .../agreement-public-routes.spec.ts | 3 +- .../unit/bookings/booking-autoassign.spec.ts | 1 + .../bookings/booking-contact-upsert.spec.ts | 1 + .../unit/bookings/booking-date-bounds.spec.ts | 1 + tests/unit/bookings/booking-people.spec.ts | 1 + .../booking-scheduled-instant.spec.ts | 1 + tests/unit/bookings/booking-toctou.spec.ts | 1 + .../booking-undeclared-timezone.spec.ts | 1 + .../unit/bookings/deposit-collection.spec.ts | 1 + .../bookings/embed-origin-allowlist.spec.ts | 1 + tests/unit/bookings/holiday-fulfill.spec.ts | 1 + tests/unit/bookings/time-window-fit.spec.ts | 1 + .../concierge-approve-primary-client.spec.ts | 2 +- .../concierge-confirm-agent-notify.spec.ts | 2 +- tests/unit/concierge/concierge-people.spec.ts | 2 +- ...ierge-resolve-token-primary-client.spec.ts | 2 +- .../unit/concierge/concierge-service.spec.ts | 2 +- .../email/outbound-cooling-send-gate.spec.ts | 16 ++ .../email/outbound-cooling-window.spec.ts | 9 + .../unit/email/tenant-email-concierge.spec.ts | 104 +++++++ .../email/tenant-email-platform-sends.spec.ts | 63 +++++ .../email/tenant-email-public-booking.spec.ts | 92 +++++++ .../email/tenant-email-public-tokens.spec.ts | 158 +++++++++++ .../unit/email/tenant-email-resolver.spec.ts | 142 ++++++++++ .../inspection-sign-unification-setup.ts | 2 + .../helpers/repair-builder-routes-harness.ts | 10 +- tests/unit/helpers/tenant-email-harness.ts | 254 ++++++++++++++++++ .../repair-builder-routes-share.spec.ts | 4 +- .../usage/plan-quota-guarded-services.spec.ts | 4 +- 44 files changed, 1053 insertions(+), 56 deletions(-) create mode 100644 server/lib/email/tenant-email-resolver.ts create mode 100644 tests/unit/email/tenant-email-concierge.spec.ts create mode 100644 tests/unit/email/tenant-email-platform-sends.spec.ts create mode 100644 tests/unit/email/tenant-email-public-booking.spec.ts create mode 100644 tests/unit/email/tenant-email-public-tokens.spec.ts create mode 100644 tests/unit/email/tenant-email-resolver.spec.ts create mode 100644 tests/unit/helpers/tenant-email-harness.ts diff --git a/server/api/agent/login.ts b/server/api/agent/login.ts index 7e6973485..6e1cc99b3 100644 --- a/server/api/agent/login.ts +++ b/server/api/agent/login.ts @@ -162,7 +162,8 @@ export const agentLoginRoutes = createApiRouter() // waitUntil so the response returns immediately in all cases. const sendPromise = (async () => { try { - await c.var.services.email.sendAgentLoginLink(email, loginUrl); + // Agent accounts belong to no company: the deployment speaks. + await c.var.services.platformEmail().sendAgentLoginLink(email, loginUrl); } catch (err) { logger.error('agent.login.link_send_failed', {}, err instanceof Error ? err : undefined); } diff --git a/server/api/agent/magic-login.ts b/server/api/agent/magic-login.ts index a110079a2..690f73c28 100644 --- a/server/api/agent/magic-login.ts +++ b/server/api/agent/magic-login.ts @@ -88,7 +88,8 @@ export const agentMagicLoginRequestRoutes = createApiRouter() // loginLink and portal.ts's requestLink. const sendPromise = (async () => { try { - await c.var.services.email.sendAgentLoginLink(result.email, result.loginUrl); + // Agent accounts belong to no company: the deployment speaks. + await c.var.services.platformEmail().sendAgentLoginLink(result.email, result.loginUrl); } catch (err) { logger.error('agent.magic_login.link_send_failed', {}, err instanceof Error ? err : undefined); } diff --git a/server/api/bookings/agreement.ts b/server/api/bookings/agreement.ts index f7b11c049..80d638254 100644 --- a/server/api/bookings/agreement.ts +++ b/server/api/bookings/agreement.ts @@ -527,7 +527,7 @@ const agreementRoutes = createApiRouter() signerEmail: signer.email, signerName: signer.name, inspectionId: result.inspectionId, - requestId: result.requestId, + requestId: result.requestId, tenantId: result.tenantId, }); } diff --git a/server/api/inspections/agreement-sign.ts b/server/api/inspections/agreement-sign.ts index 2c6ef219f..98207c7d5 100644 --- a/server/api/inspections/agreement-sign.ts +++ b/server/api/inspections/agreement-sign.ts @@ -235,6 +235,7 @@ agreementSignRoutes.post('/:id/sign', async (c) => { signerName: signer.name, inspectionId: result.inspectionId, requestId: result.requestId, + tenantId: result.tenantId, }); } diff --git a/server/api/repair-builder.ts b/server/api/repair-builder.ts index 5b30672ec..1d7f4a445 100644 --- a/server/api/repair-builder.ts +++ b/server/api/repair-builder.ts @@ -345,7 +345,7 @@ const repairBuilderRoutes = createApiRouter() const gateResult = await runShareGate(c, shareToken); if (gateResult instanceof Response) return gateResult; - const { propertyAddress } = gateResult; + const { propertyAddress, tenantId } = gateResult; await checkRateLimit(c, 'book'); @@ -354,7 +354,9 @@ const repairBuilderRoutes = createApiRouter() // The route owns the LINK; the email service owns the email. Building // the HTML here is what left this send unbranded, uneditable and // unclassified while every registry-backed send was none of those. - await c.var.services.email.sendRepairRequestShare(body.to, { + // The share token names the tenant, so its service is asked for by id. + const email = await c.var.services.emailFor(tenantId); + await email.sendRepairRequestShare(body.to, { propertyAddress: propertyAddress || '', shareUrl: `${baseUrl}/repair-request/${shareToken}`, message: body.message, diff --git a/server/lib/email/build-email-service.ts b/server/lib/email/build-email-service.ts index 3bff440a0..2aac03152 100644 --- a/server/lib/email/build-email-service.ts +++ b/server/lib/email/build-email-service.ts @@ -115,8 +115,8 @@ export function assembleTenantEmailService( env: EmailServiceEnv, cfg: LoadedEmailConfig, meterTenantId?: string, - quotaGuard?: PlanQuotaGuard, - tenantTier?: string, + quotaGuard?: PlanQuotaGuard, tenantTier?: string, + options: { recipientInitiated?: boolean } = {}, ): EmailService { const { emailIdentity, emailBrand, dbSecrets, emailOverrides, emailByoProvider } = cfg; // The ONE place an absent answer becomes `false` (a platform-default @@ -263,7 +263,7 @@ export function assembleTenantEmailService( // satisfies structurally — so a Cloudflare Workflow env reaches the seam // here with no cast, which is the whole point of that narrowing. const profile = getDeploymentProfile(env); - const cooling = meterTenantId && coolingWindowApplies({ profile, platformFunded: !isByo }) + const cooling = meterTenantId && coolingWindowApplies({ profile, platformFunded: !isByo, recipientInitiated: options.recipientInitiated }) ? buildOutboundCoolingWindow(env.DB, meterTenantId) : undefined; diff --git a/server/lib/email/outbound-cooling-window.ts b/server/lib/email/outbound-cooling-window.ts index 43efde505..7866d7cbf 100644 --- a/server/lib/email/outbound-cooling-window.ts +++ b/server/lib/email/outbound-cooling-window.ts @@ -112,8 +112,20 @@ export function isAccountEmailClass(classId: string | undefined): boolean { export function coolingWindowApplies(input: { profile: Pick; platformFunded: boolean; + /** + * The send answers something its recipient just did: a booking made on a + * public page that is already behind bot verification and a rate limit, or + * a signature the recipient has just given. Holding it would leave the very + * person who acted with no confirmation, and a new company's first customer + * would see nothing at all. + * + * This is decided per CALL SITE, never per notification class. The same + * class (a booking confirmation) is also produced by automation rules for + * people who did nothing, and the recipient of that one did not ask for it. + */ + recipientInitiated?: boolean | undefined; }): boolean { - return input.profile.mode === 'saas' && input.platformFunded; + return input.profile.mode === 'saas' && input.platformFunded && input.recipientInitiated !== true; } /** The instant the window closes, from the CORE `tenants.created_at` anchor. */ diff --git a/server/lib/email/tenant-email-resolver.ts b/server/lib/email/tenant-email-resolver.ts new file mode 100644 index 000000000..69f875dda --- /dev/null +++ b/server/lib/email/tenant-email-resolver.ts @@ -0,0 +1,99 @@ +import type { EmailService } from '../../services/email.service'; +import { readTenantPlan, type PlanQuotaGuard } from '../../features/plan-quota/guard'; +import { memoOnce } from '../request-scope'; +import { assembleTenantEmailService, loadTenantEmailConfig, type EmailServiceEnv } from './build-email-service'; + +/** + * Explicit per-tenant email resolution for one request. + * + * WHY THIS EXISTS. The DI middleware loads a tenant's email config eagerly, but + * only when the tenant is already known at that point AND the request is an API + * call. That gate is deliberate (a page render re-enters the middleware chain + * many times, and non-API requests must not pay D1 reads for a send they never + * make), so it stays. What it cannot see is a route that learns its tenant + * INSIDE the handler, after the DI middleware has already run: a public booking + * that names its company in the body, a token that names its tenant when + * redeemed, a global agent session whose company comes from the request. On + * those routes `services.email` is the platform-default service, so the mail + * would go out without the tenant's sender name, Reply-To, brand, own provider + * or template overrides, and unmetered. + * + * A send site that knows its tenant therefore asks for that tenant's service + * by id, and a send that deliberately speaks for the deployment says so. + */ +export interface EmailForOptions { + /** + * The send answers something its recipient just did (a booking they made, a + * signature they gave). It is the tenant's own service in every respect but + * one: it has no new-company cooling window. Never reuse this for a send the + * recipient did not trigger. + */ + recipientInitiated?: boolean | undefined; +} + +export interface TenantEmailResolver { + /** The service configured for `tenantId`: its identity, transport, gates and meter. */ + emailFor(tenantId: string, options?: EmailForOptions): Promise; + /** + * The deployment's own voice, for sends that belong to no company (a + * global account's sign-in link). In a single-tenant deployment the + * deployment IS its fixed tenant, so this is that tenant's service. + */ + platformEmail(): EmailService; +} + +export interface TenantEmailResolverDeps { + env: EmailServiceEnv; + /** The tenant whose config the DI middleware already loaded, if it loaded one. */ + loadedTenantId: string | undefined; + /** The service DI builds from that loaded config — called only for that tenant. */ + loadedService: () => EmailService; + /** The fixed tenant of a single-tenant deployment; null when multi-tenant. */ + fixedTenantId: string | null; + /** Whether this deployment meters tenants against a usage allowance. */ + hasUsageQuota: boolean; + buildPlanQuota: () => PlanQuotaGuard | undefined; +} + +export function createTenantEmailResolver(deps: TenantEmailResolverDeps): TenantEmailResolver { + // Per-resolver (i.e. per-request) memo. `memoOnce` below only shares work + // across the in-process sub-requests of one page render; a request that + // arrived over HTTP has no such scope, and repeated calls within it must + // still not re-read D1. + const services = new Map>(); + let platform: EmailService | undefined; + + const build = async (tenantId: string, recipientInitiated: boolean): Promise => { + // The same memo keys the DI middleware uses for the same reads, so a + // tenant it loaded under a shared scope is never read twice. + const cfg = await memoOnce(deps.env, `email-cfg:${tenantId}`, () => loadTenantEmailConfig(deps.env, tenantId)); + // The tier is read for THIS tenant. A tier the tenant router put in the + // session belongs to the tenant it resolved, which need not be this one. + const plan = deps.hasUsageQuota + ? await memoOnce(deps.env, `plan:${tenantId}`, () => readTenantPlan(deps.env.DB, tenantId)).catch(() => null) + : null; + return assembleTenantEmailService(deps.env, cfg, tenantId, deps.buildPlanQuota(), plan?.tier, { recipientInitiated }); + }; + + return { + emailFor(tenantId: string, options?: EmailForOptions): Promise { + const initiated = options?.recipientInitiated === true; + // The service DI loaded has the cooling window; a recipient-initiated + // send must not share it, so it is always built separately. + if (!initiated && tenantId === deps.loadedTenantId) return Promise.resolve(deps.loadedService()); + const memoKey = initiated ? `${tenantId}#initiated` : tenantId; + const hit = services.get(memoKey); + if (hit) return hit; + const pending = memoOnce(deps.env, `email-svc:${memoKey}`, () => build(tenantId, initiated)); + services.set(memoKey, pending); + return pending; + }, + platformEmail(): EmailService { + if (deps.fixedTenantId !== null && deps.fixedTenantId === deps.loadedTenantId) { + return deps.loadedService(); + } + platform ??= assembleTenantEmailService(deps.env, { dbSecrets: {} }); + return platform; + }, + }; +} diff --git a/server/lib/middleware/di.ts b/server/lib/middleware/di.ts index d5b75048f..733412c02 100644 --- a/server/lib/middleware/di.ts +++ b/server/lib/middleware/di.ts @@ -15,6 +15,7 @@ import { BrandingService } from '../../services/branding.service'; import { LegalVersionService } from '../../services/legal-version.service'; import { drizzle } from 'drizzle-orm/d1'; import { assembleTenantEmailService, loadTenantEmailConfig, type LoadedEmailConfig } from '../email/build-email-service'; +import { createTenantEmailResolver } from '../email/tenant-email-resolver'; import { InspectionService } from '../../services/inspection.service'; import type { ImagesBinding } from '../media/strip-exif'; import { PortalService } from '../../services/portal.service'; @@ -84,9 +85,10 @@ export async function diMiddleware(c: Context, next: Next) { // A-16 — one parallel batch (identity / brand / secrets / overrides) // replacing four serial awaits. Only API requests consume these (email - // sends + AI), so page/asset requests skip the D1 reads entirely. The - // root-mounted auth duplicates (/forgot-password) send platform-branded - // mail by design, so the /api/ gate loses nothing there. + // sends + AI), so page/asset requests skip the D1 reads entirely. A route + // that learns its tenant inside the handler resolves its own email service + // with `services.emailFor(tenantId)` instead (see tenant-email-resolver.ts). + const emailCfgFor = tenantId && c.req.path.startsWith('/api/') ? tenantId : undefined; let emailCfg: LoadedEmailConfig = { dbSecrets: {} }; // The email free-tier pre-flight (below) needs the tenant's plan tier. // `tenantTier` is only populated in session-context by the public/ @@ -99,13 +101,13 @@ export async function diMiddleware(c: Context, next: Next) { // status, not just the tier). Null wherever it was not read — a session // tier carries no status, and null is deliberately NOT an entitlement. let tenantPlan: TenantPlan | null = null; - if (tenantId && c.req.path.startsWith('/api/')) { + if (emailCfgFor) { // Its own key, NOT the `secrets:` one — the shapes differ, so they // cannot share an entry; see loadEmailSecrets for where the shared // decrypt actually happens. - emailCfg = await memoOnce(c.env, `email-cfg:${tenantId}`, () => loadTenantEmailConfig(c.env, tenantId)); + emailCfg = await memoOnce(c.env, `email-cfg:${emailCfgFor}`, () => loadTenantEmailConfig(c.env, emailCfgFor)); if (!tenantTierForQuota && c.var.profile.hasUsageQuota) { - tenantPlan = await memoOnce(c.env, `plan:${tenantId}`, () => readTenantPlan(c.env.DB, tenantId)).catch(() => null); + tenantPlan = await memoOnce(c.env, `plan:${emailCfgFor}`, () => readTenantPlan(c.env.DB, emailCfgFor)).catch(() => null); tenantTierForQuota = tenantPlan?.tier; } } @@ -231,6 +233,13 @@ export async function diMiddleware(c: Context, next: Next) { case 'email': target.email = buildEmailService(); break; + case 'emailFor': + case 'platformEmail': + Object.assign(target, createTenantEmailResolver({ + env: c.env, loadedTenantId: emailCfgFor, loadedService: () => (target.email ??= buildEmailService()), + fixedTenantId: c.var.profile.fixedTenantId, hasUsageQuota: c.var.profile.hasUsageQuota, buildPlanQuota, + })); + break; case 'inspection': target.inspection = new InspectionService(c.env.DB, c.env.PHOTOS, c.get('sdb'), c.env.TENANT_CACHE, (c.env as unknown as { IMAGES?: ImagesBinding }).IMAGES, buildPlanQuota(), c.env.KEY_ENCRYPTION_SECRET || c.env.JWT_SECRET); // This middleware is the only layer holding `c.env`, so it is @@ -399,20 +408,11 @@ export async function diMiddleware(c: Context, next: Next) { target.agent = new AgentService(c.env.DB); break; case 'concierge': - { - // Agent Accounts A3 — concierge state-machine service. - // Depends on EmailService (for client/inspector/agent - // notifications) + APP_BASE_URL for the magic-link target. - if (!target.email) { - target.email = buildEmailService(); - } - target.concierge = new ConciergeService( - c.env.DB, - target.email, - c.env.APP_BASE_URL || '', - buildPlanQuota(), - ); - } + // Agent Accounts A3 — concierge state machine. Agent sessions carry + // no tenant, so it asks for each operation's tenant's email service. + target.concierge = new ConciergeService( + c.env.DB, (id) => c.var.services.emailFor(id), c.env.APP_BASE_URL || '', buildPlanQuota(), + ); break; case 'qbo': target.qbo = new QBOService( diff --git a/server/lib/sign-effects.ts b/server/lib/sign-effects.ts index 60596fd9d..c566159f0 100644 --- a/server/lib/sign-effects.ts +++ b/server/lib/sign-effects.ts @@ -128,7 +128,11 @@ export async function runEnvelopeCompletionPipeline( c.executionCtx.waitUntil((async () => { try { const built = await buildSignedConfirmation(c, requestId, inspectionId); - await c.var.services.email.sendAgreementSignedConfirmation( + // The envelope's tenant, by id: a public signing link resolves + // its tenant in the handler, after the DI middleware ran. The signer + // has just signed, so the confirmation answers their own action. + const email = await c.var.services.emailFor(tenantId, { recipientInitiated: true }); + await email.sendAgreementSignedConfirmation( clientEmail, built.inspectorEmail ? [built.inspectorEmail] : [], clientName || 'Client', @@ -215,15 +219,16 @@ async function buildSignedConfirmation( */ export async function runSignerReceiptEffects( c: Context, - args: { signerEmail: string; signerName: string; inspectionId: string | null; requestId: string }, + args: { signerEmail: string; signerName: string; inspectionId: string | null; requestId: string; tenantId: string }, ): Promise { - const { signerEmail, signerName, inspectionId, requestId } = args; + const { signerEmail, signerName, inspectionId, requestId, tenantId } = args; if (!signerEmail) return; const ip = c.req.header('cf-connecting-ip') || c.req.header('x-forwarded-for') || null; c.executionCtx.waitUntil((async () => { try { const built = await buildSignedConfirmation(c, requestId, inspectionId); - await c.var.services.email.sendAgreementSignedConfirmation( + const email = await c.var.services.emailFor(tenantId, { recipientInitiated: true }); + await email.sendAgreementSignedConfirmation( signerEmail, built.inspectorEmail ? [built.inspectorEmail] : [], signerName || 'Signer', diff --git a/server/services/booking/booking-confirmation.ts b/server/services/booking/booking-confirmation.ts index 8c3e4b43a..052cca21f 100644 --- a/server/services/booking/booking-confirmation.ts +++ b/server/services/booking/booking-confirmation.ts @@ -79,7 +79,11 @@ export async function dispatchBookingConfirmation( // for every tenant not actually in UTC. pushInspectionAfterResponse(c, tenantId, inspectionId); - const emailService = c.var.services.email; + // By tenant id: a public booking names its company in the body, so the + // DI middleware ran before anyone knew which company this mail speaks for. + // The booker asked for this message on a page behind bot verification, so + // it is not held by the new-company window (see coolingWindowApplies). + const emailService = await c.var.services.emailFor(tenantId, { recipientInitiated: true }); // Sprint 1 C-10 — the ICS invite the customer imports into Apple Calendar // or Google Calendar. diff --git a/server/services/concierge.service.ts b/server/services/concierge.service.ts index d0017379e..d499f5c50 100644 --- a/server/services/concierge.service.ts +++ b/server/services/concierge.service.ts @@ -104,14 +104,14 @@ function toMs(value: Date | number | null | undefined): number { export class ConciergeService { /** - * Free-tier usage-quota guard (optional). Present only in SaaS deploys - * with `hasUsageQuota` (see deployment-profile.ts); undefined in - * standalone, where concierge booking stays unlimited. See the - * `consumeInspection` call site in createBooking. + * `emailFor`: each send uses the email service of the tenant it is for (an + * agent session carries none). `planQuota`: free-tier usage guard, present + * only where `hasUsageQuota` (see deployment-profile.ts); undefined in + * standalone, where booking stays unlimited. See `consumeInspection`. */ constructor( private db: D1Database, - private email: EmailService, + private emailFor: (tenantId: string) => Promise, private appBaseUrl: string, private planQuota?: PlanQuotaGuard, ) {} @@ -275,7 +275,7 @@ export class ConciergeService { throw Errors.BadRequest('Choose an inspector — this company reviews agent bookings.'); } try { - await this.email.sendConciergeInspectorReview(reviewer.email, { + await (await this.emailFor(params.tenantId)).sendConciergeInspectorReview(reviewer.email, { inspectionId, clientName: params.clientName, propertyAddress: params.propertyAddress, @@ -422,7 +422,7 @@ export class ConciergeService { ) .get(); if (agentContact?.email) { - await this.email.sendConciergeConfirmedToAgent(agentContact.email, { + await (await this.emailFor(row.tenantId)).sendConciergeConfirmedToAgent(agentContact.email, { propertyAddress: insp.propertyAddress, date: insp.date, clientName: row.clientEmail, @@ -574,7 +574,7 @@ export class ConciergeService { }); const confirmUrl = `${this.appBaseUrl.replace(/\/$/, '')}/confirm/${encodeURIComponent(token)}`; try { - await this.email.sendConciergeClientConfirm(clientEmail, { + await (await this.emailFor(tenantId)).sendConciergeClientConfirm(clientEmail, { token, confirmUrl, propertyAddress: emailPayload.propertyAddress, diff --git a/server/types/app-services.ts b/server/types/app-services.ts index a5d8e491e..545c3ed1d 100644 --- a/server/types/app-services.ts +++ b/server/types/app-services.ts @@ -22,6 +22,7 @@ import type { AvailabilityService } from '../services/availability.service'; import type { BrandingService } from '../services/branding.service'; import type { LegalVersionService } from '../services/legal-version.service'; import type { EmailService } from '../services/email.service'; +import type { EmailForOptions } from '../lib/email/tenant-email-resolver'; import type { InspectionService } from '../services/inspection.service'; import type { TeamService } from '../services/team.service'; import type { TemplateService } from '../services/template.service'; @@ -69,7 +70,17 @@ export interface AppServices { branding: BrandingService; /** Immutable version rows for the tenant's own Privacy / Terms (design 6A.3). */ legalVersion: LegalVersionService; + /** + * The email service DI built for the tenant it already knew. Only routes + * whose tenant is resolved BEFORE the DI middleware (tenant JWT, path slug, + * fixed tenant) may use it — `scripts/check-tenant-email-resolution.mjs` + * holds that list. Everything else uses one of the two below. + */ email: EmailService; + /** The email service for `tenantId` — for routes that learn their tenant in the handler. */ + emailFor: (tenantId: string, options?: EmailForOptions) => Promise; + /** The deployment's own identity, for sends that belong to no company. */ + platformEmail: () => EmailService; inspection: InspectionService; team: TeamService; template: TemplateService; diff --git a/tests/unit/agent/agent-login.spec.ts b/tests/unit/agent/agent-login.spec.ts index 5148b5d2f..8e252b135 100644 --- a/tests/unit/agent/agent-login.spec.ts +++ b/tests/unit/agent/agent-login.spec.ts @@ -98,8 +98,10 @@ describe('Agent password + magic-link login (core /agent-login front door)', () app.use('*', async (c, next) => { c.env = { DB: {}, TENANT_CACHE: kv } as unknown as HonoConfig['Bindings']; c.set('keyringPromise', Promise.resolve({}) as unknown as HonoConfig['Variables']['keyringPromise']); + // The sign-in link speaks for the deployment (agents belong to no + // company), so it goes through `platformEmail()`, never `email`. c.set('services', { - email: { sendAgentLoginLink }, + platformEmail: () => ({ sendAgentLoginLink }), } as unknown as HonoConfig['Variables']['services']); await next(); }); diff --git a/tests/unit/agent/magic-login.spec.ts b/tests/unit/agent/magic-login.spec.ts index 2435bf622..950d2351a 100644 --- a/tests/unit/agent/magic-login.spec.ts +++ b/tests/unit/agent/magic-login.spec.ts @@ -104,7 +104,8 @@ describe('Agent magic-login primitive', () => { const app = withErrorHandler(new OpenAPIHono()); app.use('*', async (c, next) => { c.env = { DB: {}, TENANT_CACHE: kv } as unknown as HonoConfig['Bindings']; - c.set('services', { portalAccess: { resolveToken }, email: { sendAgentLoginLink } } as unknown as HonoConfig['Variables']['services']); + // Deployment voice: agents belong to no company (see agent-login.spec.ts). + c.set('services', { portalAccess: { resolveToken }, platformEmail: () => ({ sendAgentLoginLink }) } as unknown as HonoConfig['Variables']['services']); await next(); }); app.route('/api/agent', agentMagicLoginRequestRoutes); diff --git a/tests/unit/agreements/agreement-public-routes.spec.ts b/tests/unit/agreements/agreement-public-routes.spec.ts index 14e81af14..04464ce6a 100644 --- a/tests/unit/agreements/agreement-public-routes.spec.ts +++ b/tests/unit/agreements/agreement-public-routes.spec.ts @@ -118,7 +118,8 @@ function buildApp(db: BetterSQLite3Database, stubs: Stubs = {}) { auditLog: { append: auditAppend }, automation: { trigger: automationTrigger }, notification: { createForAllAdmins: notificationCreate }, - email: { sendAgreementSignedConfirmation: emailConfirm }, + // By the envelope's tenant: the route learns it from the token. + emailFor: async () => ({ sendAgreementSignedConfirmation: emailConfirm }), } as unknown as HonoConfig['Variables']['services']); // Wire the workflow binding onto env so the completion pipeline can fire. Object.assign(c.env, { SIGN_COMPLETION_WORKFLOW: { create: workflowCreate } }); diff --git a/tests/unit/bookings/booking-autoassign.spec.ts b/tests/unit/bookings/booking-autoassign.spec.ts index 75db8abb4..2cd9e926f 100644 --- a/tests/unit/bookings/booking-autoassign.spec.ts +++ b/tests/unit/bookings/booking-autoassign.spec.ts @@ -66,6 +66,7 @@ function makeServiceStubs(bookingSvc: BookingService) { email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined), }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined), }, diff --git a/tests/unit/bookings/booking-contact-upsert.spec.ts b/tests/unit/bookings/booking-contact-upsert.spec.ts index 2eb7e2a56..fc2547d2b 100644 --- a/tests/unit/bookings/booking-contact-upsert.spec.ts +++ b/tests/unit/bookings/booking-contact-upsert.spec.ts @@ -71,6 +71,7 @@ function makeServiceStubs(bookingSvc: BookingService, contactSvc: ContactService email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined), }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined), }, diff --git a/tests/unit/bookings/booking-date-bounds.spec.ts b/tests/unit/bookings/booking-date-bounds.spec.ts index 60afb891b..9dd1f4b2c 100644 --- a/tests/unit/bookings/booking-date-bounds.spec.ts +++ b/tests/unit/bookings/booking-date-bounds.spec.ts @@ -150,6 +150,7 @@ describe('booking date bounds — the public endpoint', () => { recordEvent: vi.fn().mockResolvedValue(undefined), }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/bookings/booking-people.spec.ts b/tests/unit/bookings/booking-people.spec.ts index 4120de824..9c3838daf 100644 --- a/tests/unit/bookings/booking-people.spec.ts +++ b/tests/unit/bookings/booking-people.spec.ts @@ -73,6 +73,7 @@ function makeServiceStubs(bookingSvc: BookingService, contactSvc: ContactService email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined), }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined), }, diff --git a/tests/unit/bookings/booking-scheduled-instant.spec.ts b/tests/unit/bookings/booking-scheduled-instant.spec.ts index d592d5242..47ca7c04a 100644 --- a/tests/unit/bookings/booking-scheduled-instant.spec.ts +++ b/tests/unit/bookings/booking-scheduled-instant.spec.ts @@ -96,6 +96,7 @@ describe('fulfillBooking sets the precise scheduled instant', () => { recordEvent: vi.fn().mockResolvedValue(undefined), }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/bookings/booking-toctou.spec.ts b/tests/unit/bookings/booking-toctou.spec.ts index 3ef40f610..3fde34f36 100644 --- a/tests/unit/bookings/booking-toctou.spec.ts +++ b/tests/unit/bookings/booking-toctou.spec.ts @@ -222,6 +222,7 @@ describe('B-28 booking TOCTOU', () => { c.set('services', { booking: svc, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, } as unknown as HonoConfig['Variables']['services']); await next(); diff --git a/tests/unit/bookings/booking-undeclared-timezone.spec.ts b/tests/unit/bookings/booking-undeclared-timezone.spec.ts index d67d028ec..b33ecdcb2 100644 --- a/tests/unit/bookings/booking-undeclared-timezone.spec.ts +++ b/tests/unit/bookings/booking-undeclared-timezone.spec.ts @@ -130,6 +130,7 @@ describe('public booking refuses a workspace with no declared timezone', () => { recordEvent: vi.fn().mockResolvedValue(undefined), }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/bookings/deposit-collection.spec.ts b/tests/unit/bookings/deposit-collection.spec.ts index 773ddcae2..3c2b07d45 100644 --- a/tests/unit/bookings/deposit-collection.spec.ts +++ b/tests/unit/bookings/deposit-collection.spec.ts @@ -129,6 +129,7 @@ function buildApp(createdInspectionIds: string[]) { booking: svc, widget: { isOriginAllowed: vi.fn().mockResolvedValue(true), recordEvent: vi.fn().mockResolvedValue(undefined) }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, contact: { upsertClientContact: vi.fn().mockResolvedValue({ id: 'c1' }) }, diff --git a/tests/unit/bookings/embed-origin-allowlist.spec.ts b/tests/unit/bookings/embed-origin-allowlist.spec.ts index 2b01af1d4..10b5b3a3a 100644 --- a/tests/unit/bookings/embed-origin-allowlist.spec.ts +++ b/tests/unit/bookings/embed-origin-allowlist.spec.ts @@ -72,6 +72,7 @@ function buildApp(db: BetterSQLite3Database, bookingSvc: BookingS booking: bookingSvc, widget, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/bookings/holiday-fulfill.spec.ts b/tests/unit/bookings/holiday-fulfill.spec.ts index 222b98d31..0bf0a905c 100644 --- a/tests/unit/bookings/holiday-fulfill.spec.ts +++ b/tests/unit/bookings/holiday-fulfill.spec.ts @@ -95,6 +95,7 @@ describe('fulfillBooking holiday block', () => { recordEvent: vi.fn().mockResolvedValue(undefined), }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/bookings/time-window-fit.spec.ts b/tests/unit/bookings/time-window-fit.spec.ts index 04d494305..69aed46ce 100644 --- a/tests/unit/bookings/time-window-fit.spec.ts +++ b/tests/unit/bookings/time-window-fit.spec.ts @@ -69,6 +69,7 @@ function makeServiceStubs(bookingSvc: BookingService) { recordEvent: vi.fn().mockResolvedValue(undefined), }, email: { sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }, + emailFor: async () => ({ sendBookingConfirmation: vi.fn().mockResolvedValue(undefined) }), notification: { createForAllAdmins: vi.fn().mockResolvedValue(undefined) }, automation: { trigger: vi.fn().mockResolvedValue(undefined) }, inspectionRequest: { diff --git a/tests/unit/concierge/concierge-approve-primary-client.spec.ts b/tests/unit/concierge/concierge-approve-primary-client.spec.ts index 9bcaad70e..91c489e18 100644 --- a/tests/unit/concierge/concierge-approve-primary-client.spec.ts +++ b/tests/unit/concierge/concierge-approve-primary-client.spec.ts @@ -61,7 +61,7 @@ describe('ConciergeService.approveByInspector — primary-client resolution (Tas await people.addPerson(TENANT, INSP_ID, CLIENT, roleProfileId('client')); stubEmail = { sendConciergeClientConfirm: vi.fn().mockResolvedValue(undefined) }; - svc = new ConciergeService({} as D1Database, stubEmail as unknown as EmailService, 'https://acme.example.com'); + svc = new ConciergeService({} as D1Database, async () => stubEmail as unknown as EmailService, 'https://acme.example.com'); }); it('mints the client-confirm token and emails the primary client resolved via PeopleService', async () => { diff --git a/tests/unit/concierge/concierge-confirm-agent-notify.spec.ts b/tests/unit/concierge/concierge-confirm-agent-notify.spec.ts index df230ae3d..21ef2d3cf 100644 --- a/tests/unit/concierge/concierge-confirm-agent-notify.spec.ts +++ b/tests/unit/concierge/concierge-confirm-agent-notify.spec.ts @@ -76,7 +76,7 @@ describe('ConciergeService.confirmByClient — buyer_agent notify sourcing (Task stubEmail = { sendConciergeConfirmedToAgent: vi.fn().mockResolvedValue(undefined) }; svc = new ConciergeService( {} as D1Database, - stubEmail as unknown as EmailService, + async () => stubEmail as unknown as EmailService, 'https://acme.example.com', ); }); diff --git a/tests/unit/concierge/concierge-people.spec.ts b/tests/unit/concierge/concierge-people.spec.ts index f586d93f5..7562c5841 100644 --- a/tests/unit/concierge/concierge-people.spec.ts +++ b/tests/unit/concierge/concierge-people.spec.ts @@ -113,7 +113,7 @@ describe('ConciergeService.createBooking — writes inspection_people (Task 7b)' sendConciergeConfirmedToAgent: vi.fn().mockResolvedValue(undefined), sendConciergeCancelledToAgent: vi.fn().mockResolvedValue(undefined), }; - svc = new ConciergeService({} as D1Database, stubEmail as unknown as EmailService, 'https://acme.example.com'); + svc = new ConciergeService({} as D1Database, async () => stubEmail as unknown as EmailService, 'https://acme.example.com'); people = new PeopleService({ DB: {} as D1Database }); }); diff --git a/tests/unit/concierge/concierge-resolve-token-primary-client.spec.ts b/tests/unit/concierge/concierge-resolve-token-primary-client.spec.ts index f4c77d26a..50648f39a 100644 --- a/tests/unit/concierge/concierge-resolve-token-primary-client.spec.ts +++ b/tests/unit/concierge/concierge-resolve-token-primary-client.spec.ts @@ -75,7 +75,7 @@ describe('ConciergeService.resolveToken — primary-client sourcing (Task 9c)', createdAt: new Date(), }); - svc = new ConciergeService({} as D1Database, {} as unknown as EmailService, 'https://acme.example.com'); + svc = new ConciergeService({} as D1Database, async () => ({} as unknown as EmailService), 'https://acme.example.com'); }); it('resolves inspection.clientName/.clientEmail from the inspection_people primary-client join', async () => { diff --git a/tests/unit/concierge/concierge-service.spec.ts b/tests/unit/concierge/concierge-service.spec.ts index ae1c82522..6cffc3e7e 100644 --- a/tests/unit/concierge/concierge-service.spec.ts +++ b/tests/unit/concierge/concierge-service.spec.ts @@ -105,7 +105,7 @@ describe('ConciergeService — A3', () => { sendConciergeConfirmedToAgent: vi.fn().mockResolvedValue(undefined), sendConciergeCancelledToAgent: vi.fn().mockResolvedValue(undefined), }; - svc = new ConciergeService({} as D1Database, stubEmail as unknown as EmailService, 'https://acme.example.com'); + svc = new ConciergeService({} as D1Database, async () => stubEmail as unknown as EmailService, 'https://acme.example.com'); }); describe('createBooking', () => { diff --git a/tests/unit/email/outbound-cooling-send-gate.spec.ts b/tests/unit/email/outbound-cooling-send-gate.spec.ts index 77a9b2c4d..dfd6f0690 100644 --- a/tests/unit/email/outbound-cooling-send-gate.spec.ts +++ b/tests/unit/email/outbound-cooling-send-gate.spec.ts @@ -127,6 +127,22 @@ describe('24-hour outbound cooling window', () => { ).resolves.toEqual({ delivered: true }); }); + it('lets a recipient-initiated send through from the same new company, and still holds the class otherwise', async () => { + await seedTenant('tenant-initiated', 60 * 60 * 1000); + const initiated = assembleTenantEmailService(saasEnv, platformCfg, 'tenant-initiated', undefined, undefined, { recipientInitiated: true }); + await expect( + initiated.sendEmail(['client@example.com'], 'Booked', '

x

', undefined, { classId: 'booking-confirmation' }), + ).resolves.toEqual({ delivered: true }); + + // The notification class is not what earns the exemption: the same class, + // sent by a service that was not built for a recipient-initiated send + // (an automation rule firing it for someone who did nothing), is held. + const ordinary = assembleTenantEmailService(saasEnv, platformCfg, 'tenant-initiated'); + await expect( + ordinary.sendEmail(['client@example.com'], 'Booked', '

x

', undefined, { classId: 'booking-confirmation' }), + ).rejects.toMatchObject({ status: 403, code: 'OUTBOUND_COOLING_WINDOW' }); + }); + it('allows the same send once the window has elapsed', async () => { await seedTenant('tenant-old', COOLING_WINDOW_MS + 60_000); const svc = assembleTenantEmailService(saasEnv, platformCfg, 'tenant-old'); diff --git a/tests/unit/email/outbound-cooling-window.spec.ts b/tests/unit/email/outbound-cooling-window.spec.ts index 5b965d8bf..46d7d0b2d 100644 --- a/tests/unit/email/outbound-cooling-window.spec.ts +++ b/tests/unit/email/outbound-cooling-window.spec.ts @@ -103,6 +103,15 @@ describe('coolingWindowApplies', () => { it("does not apply to a tenant's own provider even on SaaS", () => { expect(coolingWindowApplies({ profile: SAAS_PROFILE, platformFunded: false })).toBe(false); }); + + it('does not apply to a send that answers something the recipient just did', () => { + expect(coolingWindowApplies({ profile: SAAS_PROFILE, platformFunded: true, recipientInitiated: true })).toBe(false); + }); + + it('is unchanged when the send is not recipient-initiated', () => { + expect(coolingWindowApplies({ profile: SAAS_PROFILE, platformFunded: true, recipientInitiated: false })).toBe(true); + expect(coolingWindowApplies({ profile: SAAS_PROFILE, platformFunded: true, recipientInitiated: undefined })).toBe(true); + }); }); describe('unlockAtMs', () => { diff --git a/tests/unit/email/tenant-email-concierge.spec.ts b/tests/unit/email/tenant-email-concierge.spec.ts new file mode 100644 index 000000000..bde74f5d0 --- /dev/null +++ b/tests/unit/email/tenant-email-concierge.spec.ts @@ -0,0 +1,104 @@ +/** + * Agent-submitted (concierge) bookings email in the TENANT's voice, in both modes. + * + * Agent sessions are global: an agent's token deliberately carries no tenant, + * so on `POST /api/agent/concierge-book` nothing before the DI middleware + * knows which company the booking is for — the body names it and the service + * verifies the agent's link to it. `POST /api/concierge/confirm` is reached + * with only the client's magic-link token. Every email either one sends must + * still carry that company's sender name, Reply-To and brand, metered to it. + * + * Driven through the real app and middleware chain; only the provider's HTTP + * call is intercepted (see helpers/tenant-email-harness.ts). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { eq } from 'drizzle-orm'; +import { app } from '../../../server/index'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import * as schema from '../../../server/lib/db/schema'; +import { + createHarness, mintAgentToken, observeIdentity, INSPECTOR_ID, OWNER_ID, TENANT_ID, TENANT_VOICE, + type Harness, type HarnessMode, +} from '../helpers/tenant-email-harness'; + +const AGENT_USER_ID = '44444444-0000-4000-8000-000000000001'; +const AGENT_CONTACT_ID = '44444444-0000-4000-8000-000000000002'; +const AGENT_EMAIL = 'avery@realty.test'; + +/** A global agent account, linked to the tenant through one of its contacts. */ +async function seedLinkedAgent(h: Harness): Promise { + await h.db.insert(schema.users).values({ + id: AGENT_USER_ID, tenantId: null, email: AGENT_EMAIL, passwordHash: 'h', + role: 'agent', name: 'Avery Agent', createdAt: new Date(), + }); + await h.db.insert(schema.contacts).values({ + id: AGENT_CONTACT_ID, tenantId: TENANT_ID, type: 'agent', name: 'Avery Agent', + email: AGENT_EMAIL, agentUserId: AGENT_USER_ID, createdByUserId: OWNER_ID, createdAt: new Date(), + }); +} + +async function conciergeBook(h: Harness, label: string): Promise { + const token = await mintAgentToken(h.env, AGENT_USER_ID); + const exec = makeExecutionContext({ label }); + const res = await app.request('/api/agent/concierge-book', { + method: 'POST', + headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` }, + body: JSON.stringify({ + tenantId: TENANT_ID, + inspectorUserId: INSPECTOR_ID, + date: '2026-11-02', + timeSlot: 'morning', + propertyAddress: '12 Lighthouse Road', + clientName: 'Casey Client', + clientEmail: 'casey@client.test', + agreementRequired: false, + paymentRequired: false, + }), + }, h.env, exec.ctx); + await exec.settle(); + return res; +} + +describe.each(['saas', 'standalone'])('concierge booking emails (%s)', (mode) => { + let h: Harness; + afterEach(() => h.close()); + + it('auto-confirm mode: the client confirm email, then the agent "confirmed" email, both in the tenant voice', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + await seedLinkedAgent(h); + + const booked = await conciergeBook(h, `concierge-book-${mode}`); + expect(booked.status, await booked.clone().text()).toBe(200); + const clientMail = h.sent.find((m) => m.to.includes('casey@client.test')); + // Soft, so a red run still reaches — and reports — the confirm step. + expect.soft(observeIdentity(h, clientMail)).toEqual(TENANT_VOICE); + + // The client follows the magic link from that very email. + const token = /\/confirm\/([0-9a-f]{64})/.exec(clientMail?.html ?? '')?.[1]; + expect(token, 'confirm link in the client email').toBeTruthy(); + const exec = makeExecutionContext({ label: `concierge-confirm-${mode}` }); + const confirmed = await app.request('/api/concierge/confirm', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ token }), + }, h.env, exec.ctx); + expect(confirmed.status, await confirmed.clone().text()).toBe(200); + await exec.settle(); + + const agentMail = h.sent.find((m) => m.to.includes(AGENT_EMAIL)); + expect(observeIdentity(h, agentMail)).toEqual(TENANT_VOICE); + }); + + it('reviewer mode: the inspector review email is in the tenant voice', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + await seedLinkedAgent(h); + await h.db.update(schema.tenantConfigs) + .set({ conciergeReviewRequired: true }) + .where(eq(schema.tenantConfigs.tenantId, TENANT_ID)); + + const booked = await conciergeBook(h, `concierge-review-${mode}`); + expect(booked.status, await booked.clone().text()).toBe(200); + const reviewMail = h.sent.find((m) => m.to.includes('ivan@harbor-light.test')); + expect(observeIdentity(h, reviewMail)).toEqual(TENANT_VOICE); + }); +}); diff --git a/tests/unit/email/tenant-email-platform-sends.spec.ts b/tests/unit/email/tenant-email-platform-sends.spec.ts new file mode 100644 index 000000000..940277b99 --- /dev/null +++ b/tests/unit/email/tenant-email-platform-sends.spec.ts @@ -0,0 +1,63 @@ +/** + * The agent sign-in link is a DEPLOYMENT send, and must stay one. + * + * Agent accounts are global — they belong to no company — so the link that + * signs one in speaks for the deployment, not for any tenant the agent happens + * to work with. In a multi-tenant deployment that is the platform identity: + * the bare platform sender, no Reply-To, no tenant brand, metered to nobody. + * + * A single-tenant deployment IS its one company, so there the deployment's + * identity is the fixed tenant's, exactly as before this send was made + * explicit. Both are pinned so the refactor that named the intent cannot have + * quietly changed either answer. + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { app } from '../../../server/index'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import * as schema from '../../../server/lib/db/schema'; +import { + createHarness, observeIdentity, PLATFORM_IDENTITY, TENANT_VOICE, + type Harness, type HarnessMode, type ObservedIdentity, +} from '../helpers/tenant-email-harness'; + +const AGENT_EMAIL = 'avery@realty.test'; + +const PLATFORM_VOICE: ObservedIdentity = { + from: PLATFORM_IDENTITY.senderEmail, + replyTo: null, + tenantBrandInHtml: false, + meteredToTenant: false, +}; + +const EXPECTED: Record = { + saas: PLATFORM_VOICE, + // The fixed tenant's sender name, Reply-To and meter. The body is drawn in + // the platform brand in every mode: the template itself declares + // `brand: 'platform'` (an account-level sign-in has no company to show). + standalone: { ...TENANT_VOICE, tenantBrandInHtml: false }, +}; + +describe.each(['saas', 'standalone'])('POST /api/agent/login-link identity (%s)', (mode) => { + let h: Harness; + afterEach(() => h.close()); + + it('speaks for the deployment', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + await h.db.insert(schema.users).values({ + id: '55555555-0000-4000-8000-000000000001', tenantId: null, email: AGENT_EMAIL, + passwordHash: 'h', role: 'agent', name: 'Avery Agent', createdAt: new Date(), + }); + + const exec = makeExecutionContext({ label: `agent-login-link-${mode}` }); + const res = await app.request('/api/agent/login-link', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ email: AGENT_EMAIL }), + }, h.env, exec.ctx); + expect(res.status, await res.clone().text()).toBe(200); + await exec.settle(); + + const mail = h.sent.find((m) => m.to.includes(AGENT_EMAIL)); + expect(observeIdentity(h, mail)).toEqual(EXPECTED[mode]); + }); +}); diff --git a/tests/unit/email/tenant-email-public-booking.spec.ts b/tests/unit/email/tenant-email-public-booking.spec.ts new file mode 100644 index 000000000..daab608d3 --- /dev/null +++ b/tests/unit/email/tenant-email-public-booking.spec.ts @@ -0,0 +1,92 @@ +/** + * Public booking confirmation speaks for the TENANT, in both deployment modes. + * + * `POST /api/public/book` names its company in the request body, so in a + * multi-tenant deployment nothing before the DI middleware knows the tenant: + * the handler resolves it. The confirmation email must still carry that + * company's sender name, Reply-To and brand, and be metered against it. In a + * single-tenant deployment the fixed tenant is known for every request, which + * is why the two modes are asserted side by side here. + * + * Driven through the real app and the real middleware chain; only the + * provider's HTTP call is intercepted (see helpers/tenant-email-harness.ts). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { app } from '../../../server/index'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import { nextWeekday } from '../helpers/bookable-date'; +import * as schema from '../../../server/lib/db/schema'; +import { + createHarness, observeIdentity, INSPECTOR_ID, OWNER_ID, TENANT_ID, TENANT_SLUG, TENANT_VOICE, + type Harness, type HarnessMode, +} from '../helpers/tenant-email-harness'; + +const MONDAY = nextWeekday(1); + +async function seedAvailability(h: Harness): Promise { + await h.db.insert(schema.availability).values([ + { id: 'av-1', tenantId: TENANT_ID, inspectorId: OWNER_ID, dayOfWeek: 1, startTime: '08:00', endTime: '12:00', createdAt: new Date() }, + { id: 'av-2', tenantId: TENANT_ID, inspectorId: INSPECTOR_ID, dayOfWeek: 1, startTime: '08:00', endTime: '12:00', createdAt: new Date() }, + ]); +} + +describe.each(['saas', 'standalone'])('POST /api/public/book confirmation identity (%s)', (mode) => { + let h: Harness; + afterEach(() => h.close()); + + it('sends the confirmation with the tenant sender name, Reply-To and brand, metered to the tenant', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + await seedAvailability(h); + const exec = makeExecutionContext({ label: `book-${mode}` }); + + const res = await app.request('/api/public/book', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tenant: TENANT_SLUG, + address: '12 Lighthouse Road Portland', + clientName: 'Casey Client', + clientEmail: 'casey@client.test', + date: MONDAY, + timeSlot: 'morning', + turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX', + }), + }, h.env, exec.ctx); + expect(res.status, await res.clone().text()).toBe(200); + await exec.settle(); + + const confirmation = h.sent.find((m) => m.to.includes('casey@client.test')); + expect(observeIdentity(h, confirmation)).toEqual(TENANT_VOICE); + }); +}); + +describe('POST /api/public/book from a company that signed up an hour ago (saas)', () => { + let h: Harness; + afterEach(() => h.close()); + + it('is not held by the 24-hour outbound window: the first customer of a new company still gets a confirmation', { timeout: 60_000 }, async () => { + h = await createHarness('saas', { tenantAgeMs: 60 * 60 * 1000 }); + await seedAvailability(h); + const exec = makeExecutionContext({ label: 'book-first-day' }); + + const res = await app.request('/api/public/book', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + tenant: TENANT_SLUG, + address: '12 Lighthouse Road Portland', + clientName: 'Casey Client', + clientEmail: 'casey@client.test', + date: MONDAY, + timeSlot: 'morning', + turnstileToken: 'XXXX.DUMMY.TOKEN.XXXX', + }), + }, h.env, exec.ctx); + expect(res.status, await res.clone().text()).toBe(200); + await exec.settle(); + + const confirmation = h.sent.find((m) => m.to.includes('casey@client.test')); + expect(confirmation, 'the confirmation was not sent').toBeDefined(); + expect(observeIdentity(h, confirmation)).toEqual(TENANT_VOICE); + }); +}); diff --git a/tests/unit/email/tenant-email-public-tokens.spec.ts b/tests/unit/email/tenant-email-public-tokens.spec.ts new file mode 100644 index 000000000..ac054be98 --- /dev/null +++ b/tests/unit/email/tenant-email-public-tokens.spec.ts @@ -0,0 +1,158 @@ +/** + * Token-authenticated public sends speak for the TENANT, in both modes. + * + * Both routes below are reached with nothing but a token in the URL: the + * remote agreement signature (`POST /api/public/agreements/{token}/sign`) and + * the repair-list share email (`POST /api/public/repair-request/share/{token}/email`). + * The token names the tenant, and the handler learns it — after the DI + * middleware has already run. Every email those handlers send must still carry + * the tenant's sender name, Reply-To and brand, and be metered to it. + * + * Driven through the real app and middleware chain; only the provider's HTTP + * call is intercepted (see helpers/tenant-email-harness.ts). + */ +import { describe, it, expect, afterEach } from 'vitest'; +import { app } from '../../../server/index'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import * as schema from '../../../server/lib/db/schema'; +import { AgreementService } from '../../../server/services/agreement.service'; +import { RepairRequestService } from '../../../server/services/repair-request.service'; +import { + createHarness, observeIdentity, INSPECTOR_ID, TENANT_ID, TENANT_VOICE, + type Harness, type HarnessMode, +} from '../helpers/tenant-email-harness'; + +const INSPECTION_ID = '33333333-0000-4000-8000-000000000001'; +const AGREEMENT_ID = '33333333-0000-4000-8000-000000000002'; +const SIGNATURE = 'data:image/png;base64,aGVsbG8='; + +async function seedInspection(h: Harness, reportStatus: 'in_progress' | 'published', agreementRequired: boolean): Promise { + await h.db.insert(schema.inspections).values({ + id: INSPECTION_ID, tenantId: TENANT_ID, inspectorId: INSPECTOR_ID, + propertyAddress: '12 Lighthouse Road', date: '2026-06-01', status: 'requested', + paymentStatus: 'unpaid', price: 0, agreementRequired, paymentRequired: false, + reportStatus, createdAt: new Date(), + }); +} + +function post(body: Record): RequestInit { + return { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) }; +} + +describe.each(['saas', 'standalone'])('token-authenticated public sends (%s)', (mode) => { + let h: Harness; + afterEach(() => h.close()); + + it('agreement signature: the per-signer receipt AND the completion confirmation use the tenant identity', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + await seedInspection(h, 'in_progress', true); + await h.db.insert(schema.agreements).values({ + id: AGREEMENT_ID, tenantId: TENANT_ID, name: 'Standard Agreement', + content: 'Agreement text', version: 1, createdAt: new Date(), + }); + const svc = new AgreementService(h.env.DB, { jwtSecret: h.env.JWT_SECRET }); + const envelope = await svc.findOrCreate(TENANT_ID, INSPECTION_ID, { + signers: [ + { name: 'Casey Client', email: 'casey@client.test', role: 'client' }, + { name: 'Cory Coclient', email: 'cory@client.test', role: 'co_client' }, + ], + completionPolicy: 'all', + }); + const signers = await h.db.select().from(schema.agreementSigners).all(); + const second = signers.find((s) => s.email === 'cory@client.test'); + const tokenTwo = await svc.getSignerLink(TENANT_ID, envelope.requestId, second!.id); + + for (const token of [envelope.token, tokenTwo]) { + const view = makeExecutionContext({ label: `present-${mode}` }); + const presented = await app.request(`/api/public/agreements/${token}`, {}, h.env, view.ctx); + expect(presented.status, await presented.clone().text()).toBe(200); + await view.settle(); + const exec = makeExecutionContext({ label: `sign-${mode}` }); + const signed = await app.request(`/api/public/agreements/${token}/sign`, post({ signatureBase64: SIGNATURE }), h.env, exec.ctx); + expect(signed.status, await signed.clone().text()).toBe(200); + await exec.settle(); + } + + // First signature: a receipt to signer one. Second signature completes + // the envelope: the completion confirmation to the client, and a + // receipt to signer two (each CCs the inspector). Three sends, every + // one in the tenant's voice. + expect(h.sent.map((m) => m.to[0]).sort()).toEqual( + ['casey@client.test', 'casey@client.test', 'cory@client.test'], + ); + for (const mail of h.sent) { + expect(observeIdentity(h, mail)).toEqual(TENANT_VOICE); + } + }); + + it('repair-list share email uses the tenant identity of the share token', { timeout: 60_000 }, async () => { + h = await createHarness(mode); + // Published and not held back: no agreement or payment stands in front of it. + await seedInspection(h, 'published', false); + const request = await new RepairRequestService(h.env.DB) + .create(TENANT_ID, INSPECTION_ID, { kind: 'client', ref: 'casey@client.test' }); + + const exec = makeExecutionContext({ label: `share-${mode}` }); + const res = await app.request( + `/api/public/repair-request/share/${request.shareToken}/email`, + post({ to: 'contractor@trade.test', message: 'Please quote these items.' }), + h.env, exec.ctx, + ); + expect(res.status, await res.clone().text()).toBe(200); + await exec.settle(); + + const share = h.sent.find((m) => m.to.includes('contractor@trade.test')); + expect(observeIdentity(h, share)).toEqual(TENANT_VOICE); + }); +}); + +describe('token-authenticated public sends from a company that signed up an hour ago (saas)', () => { + let h: Harness; + afterEach(() => h.close()); + + it('the signature confirmation reaches the signer: the window does not hold what the recipient just triggered', { timeout: 60_000 }, async () => { + h = await createHarness('saas', { tenantAgeMs: 60 * 60 * 1000 }); + await seedInspection(h, 'in_progress', true); + await h.db.insert(schema.agreements).values({ + id: AGREEMENT_ID, tenantId: TENANT_ID, name: 'Standard Agreement', + content: 'Agreement text', version: 1, createdAt: new Date(), + }); + const svc = new AgreementService(h.env.DB, { jwtSecret: h.env.JWT_SECRET }); + const envelope = await svc.findOrCreate(TENANT_ID, INSPECTION_ID, { + signers: [{ name: 'Casey Client', email: 'casey@client.test', role: 'client' }], + completionPolicy: 'all', + }); + + const view = makeExecutionContext({ label: 'present-first-day' }); + const presented = await app.request(`/api/public/agreements/${envelope.token}`, {}, h.env, view.ctx); + expect(presented.status, await presented.clone().text()).toBe(200); + await view.settle(); + const exec = makeExecutionContext({ label: 'sign-first-day' }); + const signed = await app.request(`/api/public/agreements/${envelope.token}/sign`, post({ signatureBase64: SIGNATURE }), h.env, exec.ctx); + expect(signed.status, await signed.clone().text()).toBe(200); + await exec.settle(); + + const toSigner = h.sent.filter((m) => m.to.includes('casey@client.test')); + expect(toSigner.length, 'no confirmation reached the signer').toBeGreaterThan(0); + for (const mail of toSigner) { + expect(observeIdentity(h, mail)).toEqual(TENANT_VOICE); + } + }); + + it('control: a share email to an address the company chose is still held in the first 24 hours', { timeout: 60_000 }, async () => { + h = await createHarness('saas', { tenantAgeMs: 60 * 60 * 1000 }); + await seedInspection(h, 'published', false); + const request = await new RepairRequestService(h.env.DB) + .create(TENANT_ID, INSPECTION_ID, { kind: 'client', ref: 'casey@client.test' }); + + const exec = makeExecutionContext({ label: 'share-first-day' }); + await app.request( + `/api/public/repair-request/share/${request.shareToken}/email`, + post({ to: 'contractor@trade.test', message: 'Please quote these items.' }), + h.env, exec.ctx, + ); + await exec.settle(); + + expect(h.sent.find((m) => m.to.includes('contractor@trade.test'))).toBeUndefined(); + }); +}); diff --git a/tests/unit/email/tenant-email-resolver.spec.ts b/tests/unit/email/tenant-email-resolver.spec.ts new file mode 100644 index 000000000..e7c2f3b7f --- /dev/null +++ b/tests/unit/email/tenant-email-resolver.spec.ts @@ -0,0 +1,142 @@ +/** + * `services.emailFor(tenantId)` / `services.platformEmail()` — the explicit + * per-tenant resolution on the DI registry. + * + * Runs the real middlewares in the order `server/index.ts` registers them + * (contextBootstrap -> tenantRouter -> jwtAuth -> integrationSecrets -> di) + * in front of a probe route, so what the probe sees is what a handler sees. + * `loadTenantEmailConfig` is wrapped (not replaced) so every D1 config load is + * counted: the DI middleware's own eager load and every resolver load go + * through the same function. + */ +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { Hono } from 'hono'; +import type { HonoConfig } from '../../../server/types/hono'; +import type { EmailService } from '../../../server/services/email.service'; +import * as schema from '../../../server/lib/db/schema'; +import { contextBootstrap } from '../../../server/lib/middleware/context-bootstrap'; +import { tenantRouter } from '../../../server/features/tenant-routing'; +import { jwtAuthMiddleware } from '../../../server/lib/middleware/jwt-auth'; +import { integrationSecretsMiddleware } from '../../../server/lib/middleware/integration-secrets'; +import { diMiddleware } from '../../../server/lib/middleware/di'; +import { loadTenantEmailConfig } from '../../../server/lib/email/build-email-service'; +import { makeExecutionContext } from '../helpers/exec-ctx'; +import { + createHarness, PLATFORM_IDENTITY, TENANT_ID, TENANT_IDENTITY, type Harness, type HarnessMode, +} from '../helpers/tenant-email-harness'; + +vi.mock('../../../server/lib/email/build-email-service', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, loadTenantEmailConfig: vi.fn(actual.loadTenantEmailConfig) }; +}); + +const OTHER_TENANT_ID = '11111111-0000-4000-8000-000000000002'; +const OTHER_REPLY_TO = 'desk@second-company.test'; + +async function seedOtherTenant(h: Harness): Promise { + await h.db.insert(schema.tenants).values({ + id: OTHER_TENANT_ID, slug: 'second-company', status: 'active', tier: 'free', + maxUsers: 5, deploymentMode: 'shared', createdAt: new Date(0), + }); + await h.db.insert(schema.tenantConfigs).values({ + tenantId: OTHER_TENANT_ID, companyName: 'Second Company', senderDisplayName: 'Second Desk', + replyTo: OTHER_REPLY_TO, updatedAt: new Date(0), + }); +} + +type Probe = (services: HonoConfig['Variables']['services']) => Promise; + +/** The real chain, in index.ts order, in front of one probe handler. */ +async function runProbe(h: Harness, probe: Probe): Promise { + const app = new Hono(); + app.use('*', contextBootstrap); + app.use('*', tenantRouter); + app.use('*', jwtAuthMiddleware); + app.use('*', integrationSecretsMiddleware); + app.use('*', diMiddleware); + let failure: unknown; + app.get('/api/probe', async (c) => { + try { await probe(c.var.services); } catch (e) { failure = e; } + return c.text('ok'); + }); + const res = await app.request('/api/probe', {}, h.env, makeExecutionContext().ctx); + expect(res.status).toBe(200); + if (failure) throw failure; +} + +const loads = (): string[] => vi.mocked(loadTenantEmailConfig).mock.calls.map((call) => call[1]); + +describe('services.emailFor / services.platformEmail', () => { + let h: Harness; + afterEach(() => { + h.close(); + vi.mocked(loadTenantEmailConfig).mockClear(); + }); + + it('standalone: emailFor(the fixed tenant) IS the service DI already built — no second config load', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + await runProbe(h, async (services) => { + expect(loads()).toEqual([TENANT_ID]); // DI's own eager load for /api/ + const resolved = await services.emailFor(TENANT_ID); + expect(resolved).toBe(services.email); + expect(resolved.identity?.replyTo).toBe(TENANT_IDENTITY.replyTo); + expect(loads()).toEqual([TENANT_ID]); + }); + }); + + it.each(['saas', 'standalone'])('%s: a tenant DI did not load is loaded once, then memoised for the request', { timeout: 60_000 }, async (mode) => { + h = await createHarness(mode); + await seedOtherTenant(h); + await runProbe(h, async (services) => { + const before = loads().length; + const first = await services.emailFor(OTHER_TENANT_ID); + const again = await services.emailFor(OTHER_TENANT_ID); + expect(again).toBe(first); + expect(loads().slice(before)).toEqual([OTHER_TENANT_ID]); + expect(first.identity?.replyTo).toBe(OTHER_REPLY_TO); + expect(first.meter).toBeDefined(); // metered, to that tenant (see the route specs) + }); + }); + + it.each(['saas', 'standalone'])('%s: emailFor({ recipientInitiated }) is a separate, memoised service with no cooling window, still in the tenant voice', { timeout: 60_000 }, async (mode) => { + h = await createHarness(mode); + await runProbe(h, async (services) => { + const ordinary = await services.emailFor(TENANT_ID); + const initiated = await services.emailFor(TENANT_ID, { recipientInitiated: true }); + expect(initiated).not.toBe(ordinary); + expect(await services.emailFor(TENANT_ID, { recipientInitiated: true })).toBe(initiated); + expect(initiated.identity?.replyTo).toBe(TENANT_IDENTITY.replyTo); + expect(initiated.cooling).toBeUndefined(); + // The shared service keeps its window (saas); standalone never had one. + expect(ordinary.cooling !== undefined).toBe(mode === 'saas'); + }); + }); + + it('saas: concurrent first calls share ONE load', { timeout: 60_000 }, async () => { + h = await createHarness('saas'); + await runProbe(h, async (services) => { + const [a, b] = await Promise.all([services.emailFor(TENANT_ID), services.emailFor(TENANT_ID)]); + expect(a).toBe(b); + expect(loads()).toEqual([TENANT_ID]); + }); + }); + + it('saas: platformEmail() is the platform identity — no tenant identity, no meter, no config load', { timeout: 60_000 }, async () => { + h = await createHarness('saas'); + await runProbe(h, async (services) => { + const platform: EmailService = services.platformEmail(); + expect(platform.senderEmail).toBe(PLATFORM_IDENTITY.senderEmail); + expect(platform.identity).toBeUndefined(); + expect(platform.meter).toBeUndefined(); + expect(loads()).toEqual([]); + }); + }); + + it('standalone: platformEmail() is the deployment identity, which is the fixed tenant (unchanged behaviour)', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + await runProbe(h, async (services) => { + expect(services.platformEmail()).toBe(services.email); + expect(loads()).toEqual([TENANT_ID]); + }); + }); +}); diff --git a/tests/unit/helpers/inspection-sign-unification-setup.ts b/tests/unit/helpers/inspection-sign-unification-setup.ts index 28ed1ecf2..000e6a692 100644 --- a/tests/unit/helpers/inspection-sign-unification-setup.ts +++ b/tests/unit/helpers/inspection-sign-unification-setup.ts @@ -92,6 +92,8 @@ export function buildApp(db: TestDb, stubs: Stubs = {}) { sendAgreementSignedConfirmation: emailConfirm, sendAgreementRequest: emailAgreementRequest, }, + // The signed-confirmation sends resolve by the envelope's tenant. + emailFor: async () => ({ sendAgreementSignedConfirmation: emailConfirm }), } as unknown as HonoConfig['Variables']['services']); // `SIGN_COMPLETION_WORKFLOW` is a declared optional binding on AppEnv // (server/types/hono.ts), so this is a plain assignment — only the diff --git a/tests/unit/helpers/repair-builder-routes-harness.ts b/tests/unit/helpers/repair-builder-routes-harness.ts index 3e1814fa1..500f81a18 100644 --- a/tests/unit/helpers/repair-builder-routes-harness.ts +++ b/tests/unit/helpers/repair-builder-routes-harness.ts @@ -217,7 +217,14 @@ export function makeShareServices(overrides: { creditTotal?: ReturnType; sendRepairRequestShare?: ReturnType; } = {}) { + // The share email resolves its service by the share token's tenant + // (`emailFor`), so the same stub is what `emailFor` hands back. + const email = { + sendRepairRequestShare: overrides.sendRepairRequestShare ?? vi.fn().mockResolvedValue(undefined), + }; return { + email, + emailFor: vi.fn(async (_tenantId: string) => email), portalAccess: { resolveToken: vi.fn().mockResolvedValue(null) }, inspection: { resolveAgentViewToken: vi.fn().mockResolvedValue(null), resolveReleaseGate: vi.fn().mockResolvedValue(null) }, repairRequest: { @@ -233,9 +240,6 @@ export function makeShareServices(overrides: { setIntro: vi.fn().mockResolvedValue(undefined), assertCanEdit: vi.fn().mockResolvedValue(undefined), }, - email: { - sendRepairRequestShare: overrides.sendRepairRequestShare ?? vi.fn().mockResolvedValue(undefined), - }, }; } diff --git a/tests/unit/helpers/tenant-email-harness.ts b/tests/unit/helpers/tenant-email-harness.ts new file mode 100644 index 000000000..3e3671e8d --- /dev/null +++ b/tests/unit/helpers/tenant-email-harness.ts @@ -0,0 +1,254 @@ +/** + * Harness for the "which tenant does this email speak for" specs. + * + * It drives the REAL application (`server/index.ts`'s `app`, so the real + * middleware chain in its real order: contextBootstrap -> tenantRouter -> ... -> + * jwtAuth -> ... -> integrationSecrets -> di) over an in-memory SQLite database + * presented as a D1 binding (`toD1Binding`, so production code builds its own + * drizzle handle exactly as it does on D1). Nothing in the email pipeline is + * stubbed: the only thing intercepted is the provider's outbound HTTP call, + * through a global `fetch` double, so the assertions read the request body the + * provider would actually have received. + * + * The seeded tenant carries a DISTINCT identity (display name, Reply-To, brand + * name, brand colour) and the deployment carries a distinct PLATFORM identity, + * so any send can be classified by reading its `from` / `reply_to` / HTML. + */ +import { vi } from 'vitest'; +import { drizzle as sqliteDrizzle, type BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import type { Database as SqliteDatabase } from 'better-sqlite3'; +import { createTestDb, setupSchema } from '../db'; +import { MockKV } from '../mocks'; +import { toD1Binding } from './d1-binding'; +import { asD1Db } from './test-db'; +import * as schema from '../../../server/lib/db/schema'; +import { MeteringService } from '../../../server/services/metering.service'; +import { seedRoleProfiles } from '../../../server/services/seed/seed-role-profiles'; +import type { HonoConfig } from '../../../server/types/hono'; + +export type HarnessMode = 'saas' | 'standalone'; + +export const TENANT_ID = '11111111-0000-4000-8000-000000000001'; +export const TENANT_SLUG = 'acme-email'; +export const OWNER_ID = '22222222-0000-4000-8000-000000000001'; +export const INSPECTOR_ID = '22222222-0000-4000-8000-000000000002'; + +/** The tenant's own identity: every value differs from the platform's. */ +export const TENANT_IDENTITY = { + companyName: 'Harbor Light Home Inspections', + senderDisplayName: 'Harbor Light Office', + replyTo: 'office@harbor-light.test', + primaryColor: '#0b7a75', +} as const; + +/** The deployment's own identity. */ +export const PLATFORM_IDENTITY = { + senderEmail: 'noreply@platform.test', + appName: 'PlatformMailName', + resendKey: 're_platform_test_key', +} as const; + +export interface CapturedEmail { + from: string; + to: string[]; + subject: string; + html: string; + reply_to?: string; + /** The credential the request was sent with (a header, copied here). */ + authorization: string; +} + +export interface Harness { + sqlite: SqliteDatabase; + db: BetterSQLite3Database; + env: HonoConfig['Bindings']; + /** Every body POSTed to the Resend send endpoint, in order. */ + sent: CapturedEmail[]; + /** Tenant ids the usage meter was asked to count an email for. */ + meteredTenants: () => string[]; + close: () => void; +} + +const RESEND_SEND_URL = 'https://api.resend.com/emails'; +const TURNSTILE_VERIFY_URL = 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; + +function toPem(label: string, der: ArrayBuffer): string { + const b64 = Buffer.from(der).toString('base64'); + const lines = b64.match(/.{1,64}/g) ?? []; + return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; +} + +let keyPairPems: { privatePem: string; publicPem: string } | null = null; + +/** One ES256 pair per spec file — generating is cheap but not free. */ +async function jwtKeyPems(): Promise<{ privatePem: string; publicPem: string }> { + if (keyPairPems) return keyPairPems; + const pair = await crypto.subtle.generateKey({ name: 'ECDSA', namedCurve: 'P-256' }, true, ['sign', 'verify']); + const pkcs8 = await crypto.subtle.exportKey('pkcs8', pair.privateKey); + const spki = await crypto.subtle.exportKey('spki', pair.publicKey); + keyPairPems = { privatePem: toPem('PRIVATE KEY', pkcs8), publicPem: toPem('PUBLIC KEY', spki) }; + return keyPairPems; +} + +function bodyText(init: RequestInit | undefined): string { + const body = init?.body; + return typeof body === 'string' ? body : ''; +} + +/** + * Stub the outbound network: the email provider (recorded), Turnstile + * verification (always passes — the deployment's own policy decides whether + * it is asked at all), and nothing else. Any other host answers 404 so a send + * that leaks somewhere unexpected fails loudly rather than silently passing. + */ +function stubNetwork(sent: CapturedEmail[]): void { + vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + if (url === RESEND_SEND_URL) { + const authorization = new Headers(init?.headers).get('authorization') ?? ''; + sent.push({ ...(JSON.parse(bodyText(init)) as Omit), authorization }); + return new Response(JSON.stringify({ id: `msg_${sent.length}` }), { status: 200 }); + } + if (url === TURNSTILE_VERIFY_URL) { + return new Response(JSON.stringify({ success: true }), { status: 200 }); + } + return new Response('not stubbed', { status: 404 }); + })); +} + +/** + * `toD1Binding` binds positionally, which better-sqlite3 refuses for D1's + * numbered `?N` placeholders (the plan-quota guard uses them, and a saas + * booking reaches it). Rewrite each `?N` to a plain `?` and reorder the bound + * values to match — the same values in the same slots D1 would use. + */ +function withNumberedParams(binding: D1Database): D1Database { + return new Proxy(binding, { + get(target, prop, receiver) { + if (prop !== 'prepare') return Reflect.get(target, prop, receiver) as unknown; + return (sql: string) => { + const order = [...sql.matchAll(/\?(\d+)/g)].map((m) => Number(m[1])); + if (order.length === 0) return target.prepare(sql); + const statement = target.prepare(sql.replace(/\?\d+/g, '?')); + return new Proxy(statement, { + get(stmt, key, r) { + if (key !== 'bind') return Reflect.get(stmt, key, r) as unknown; + return (...values: unknown[]) => stmt.bind(...order.map((n) => values[n - 1])); + }, + }); + }; + }, + }); +} + +export interface HarnessOptions { + /** How long ago the tenant signed up. Default: a month, i.e. past any new-company window. */ + tenantAgeMs?: number; +} + +export async function createHarness(mode: HarnessMode, options: HarnessOptions = {}): Promise { + const { sqlite } = createTestDb(); + await setupSchema(sqlite); + const db = sqliteDrizzle(sqlite, { schema }); + const pems = await jwtKeyPems(); + + const env = { + APP_MODE: mode, + ...(mode === 'standalone' ? { SINGLE_TENANT_ID: TENANT_ID } : {}), + DB: withNumberedParams(toD1Binding(sqlite)), + TENANT_CACHE: new MockKV() as unknown as KVNamespace, + JWT_SECRET: 'j'.repeat(32), + KEY_ENCRYPTION_SECRET: 'k'.repeat(32), + JWT_CURRENT_KID: 'v1', + JWT_PRIVATE_KEY_V1: pems.privatePem, + JWT_PUBLIC_KEY_V1: pems.publicPem, + RESEND_API_KEY: PLATFORM_IDENTITY.resendKey, + SENDER_EMAIL: PLATFORM_IDENTITY.senderEmail, + APP_NAME: PLATFORM_IDENTITY.appName, + APP_BASE_URL: 'https://app.platform.test', + // Cloudflare's documented always-pass test secret. + TURNSTILE_SECRET_KEY: '1x0000000000000000000000000000000AA', + } as unknown as HonoConfig['Bindings']; + + const sent: CapturedEmail[] = []; + stubNetwork(sent); + const recordSpy = vi.spyOn(MeteringService.prototype, 'record'); + + await seedTenant(db, options.tenantAgeMs ?? 30 * 24 * 60 * 60 * 1000); + + return { + sqlite, + db, + env, + sent, + meteredTenants: () => recordSpy.mock.calls + .filter((call) => call[1] === 'email' || call[1] === 'email_byo') + .map((call) => call[0]), + close: () => { + recordSpy.mockRestore(); + vi.unstubAllGlobals(); + sqlite.close(); + }, + }; +} + +/** A tenant that signed up `ageMs` ago, with its own identity. */ +async function seedTenant(db: BetterSQLite3Database, ageMs: number): Promise { + const monthAgo = new Date(Date.now() - ageMs); + await db.insert(schema.tenants).values({ + id: TENANT_ID, slug: TENANT_SLUG, status: 'active', tier: 'free', + maxUsers: 5, deploymentMode: 'shared', createdAt: monthAgo, + }); + await db.insert(schema.users).values([ + { id: OWNER_ID, tenantId: TENANT_ID, email: 'owner@harbor-light.test', passwordHash: 'h', role: 'owner', name: 'Olive Owner', createdAt: monthAgo }, + { id: INSPECTOR_ID, tenantId: TENANT_ID, email: 'ivan@harbor-light.test', passwordHash: 'h', role: 'inspector', name: 'Ivan Inspector', createdAt: monthAgo }, + ]); + await db.insert(schema.tenantConfigs).values({ + tenantId: TENANT_ID, + companyName: TENANT_IDENTITY.companyName, + senderDisplayName: TENANT_IDENTITY.senderDisplayName, + replyTo: TENANT_IDENTITY.replyTo, + primaryColor: TENANT_IDENTITY.primaryColor, + emailMode: 'platform', + defaultTimezone: 'America/New_York', + enableCustomerRepairExport: true, + updatedAt: monthAgo, + }); + await seedRoleProfiles(asD1Db(db), TENANT_ID, monthAgo); +} + +/** Sign a token the way the agent sign-in routes do: global, no tenant claim. */ +export async function mintAgentToken(env: HonoConfig['Bindings'], agentUserId: string): Promise { + const { buildKeyring, signJwt } = await import('../../../server/lib/jwt-keyring'); + const keyring = await buildKeyring(env as unknown as Record); + const now = Math.floor(Date.now() / 1000); + return signJwt({ sub: agentUserId, role: 'agent', 'custom:userRole': 'agent', iat: now, exp: now + 3600 }, keyring); +} + +export interface ObservedIdentity { + from: string; + replyTo: string | null; + tenantBrandInHtml: boolean; + meteredToTenant: boolean; +} + +/** + * The four facts that say whose voice a send used, read off what the provider + * received. Compared as ONE object so a red run prints every fact at once. + */ +export function observeIdentity(h: Harness, mail: CapturedEmail | undefined): ObservedIdentity { + return { + from: mail?.from ?? '', + replyTo: mail?.reply_to ?? null, + tenantBrandInHtml: (mail?.html ?? '').includes(TENANT_IDENTITY.companyName), + meteredToTenant: h.meteredTenants().includes(TENANT_ID), + }; +} + +/** What a send in the tenant's voice looks like (platform transport, tenant identity). */ +export const TENANT_VOICE: ObservedIdentity = { + from: `${TENANT_IDENTITY.senderDisplayName} <${PLATFORM_IDENTITY.senderEmail}>`, + replyTo: TENANT_IDENTITY.replyTo, + tenantBrandInHtml: true, + meteredToTenant: true, +}; diff --git a/tests/unit/repair/repair-builder-routes-share.spec.ts b/tests/unit/repair/repair-builder-routes-share.spec.ts index fc743f4ba..fb6fe9343 100644 --- a/tests/unit/repair/repair-builder-routes-share.spec.ts +++ b/tests/unit/repair/repair-builder-routes-share.spec.ts @@ -20,7 +20,7 @@ vi.mock('../../../server/lib/public-access', async (importOriginal) => { }); // eslint-disable-next-line import/order -import { buildShareApp, SHARE_INSP_UNPUBLISHED } from '../helpers/repair-builder-routes-harness'; +import { buildShareApp, SHARE_INSP_UNPUBLISHED, SHARE_RR } from '../helpers/repair-builder-routes-harness'; describe('GET /api/public/repair-request/share/:shareToken', () => { it('200 with propertyAddress + customIntro + items + creditTotal for published report', async () => { @@ -173,6 +173,8 @@ describe('POST /api/public/repair-request/share/:shareToken/email', () => { const body = await res.json() as { success: boolean }; expect(body.success).toBe(true); + // The service is the share token's tenant's, asked for by id. + expect(svc.emailFor).toHaveBeenCalledWith(SHARE_RR.tenantId); expect(svc.email.sendRepairRequestShare).toHaveBeenCalledWith( 'contractor@example.com', expect.objectContaining({ diff --git a/tests/unit/usage/plan-quota-guarded-services.spec.ts b/tests/unit/usage/plan-quota-guarded-services.spec.ts index 2e7d71bf0..85663cb39 100644 --- a/tests/unit/usage/plan-quota-guarded-services.spec.ts +++ b/tests/unit/usage/plan-quota-guarded-services.spec.ts @@ -109,7 +109,7 @@ describe('ConciergeService.createBooking consumes the free-tier quota', () => { it('blocks the 6th create for a free tenant with 402/QUOTA_EXHAUSTED', async () => { const guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); - const svc = new ConciergeService(testD1, stubEmail as unknown as EmailService, 'https://acme.example.com', guard); + const svc = new ConciergeService(testD1, async () => stubEmail as unknown as EmailService, 'https://acme.example.com', guard); for (let i = 0; i < 5; i++) await svc.createBooking(baseParams()); await expect(svc.createBooking(baseParams())).rejects.toMatchObject({ @@ -120,7 +120,7 @@ describe('ConciergeService.createBooking consumes the free-tier quota', () => { it('a rejected validation (unknown inspector contact) does not consume quota', async () => { const guard = new PlanQuotaGuard(testD1, { enforced: true, billingPortalUrl: null }); - const svc = new ConciergeService(testD1, stubEmail as unknown as EmailService, 'https://acme.example.com', guard); + const svc = new ConciergeService(testD1, async () => stubEmail as unknown as EmailService, 'https://acme.example.com', guard); await expect(svc.createBooking({ ...baseParams(), inspectorContactId: 'no-such-contact' })) .rejects.toThrow(/not found/i); From 767c90cb269dafdae7b23c90d4befa40f29bed64 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:21:27 -0400 Subject: [PATCH 3/5] chore(gates): forbid unresolved services.email outside tenant-resolved 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 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- package.json | 1 + scripts/check-tenant-email-resolution.mjs | 160 ++++++++++++++++++ scripts/lib/gate-registry.mjs | 3 + scripts/tenant-email-allowlist.json | 14 ++ tests/unit/tooling/gate-registry.spec.ts | 6 + .../tenant-email-resolution-gate.spec.ts | 89 ++++++++++ 6 files changed, 273 insertions(+) create mode 100644 scripts/check-tenant-email-resolution.mjs create mode 100644 scripts/tenant-email-allowlist.json create mode 100644 tests/unit/tooling/tenant-email-resolution-gate.spec.ts diff --git a/package.json b/package.json index 96503f1fb..3adf23ee4 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "lint:capability-readers": "node scripts/check-capability-readers.mjs", "lint:mode-disguises": "node scripts/check-mode-disguises.mjs", "lint:provider-helpers": "node scripts/check-provider-helpers.mjs", + "lint:tenant-email": "node scripts/check-tenant-email-resolution.mjs", "lint:notification-dispatch": "node scripts/check-notification-dispatch.mjs", "lint:tests": "node scripts/check-test-layout.mjs", "lint:e2e-coverage": "node scripts/check-e2e-spec-coverage.mjs", diff --git a/scripts/check-tenant-email-resolution.mjs b/scripts/check-tenant-email-resolution.mjs new file mode 100644 index 000000000..5c7ab3f9f --- /dev/null +++ b/scripts/check-tenant-email-resolution.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Tenant email resolution gate (`lint:tenant-email`). + * + * `services.email` is the email service the DI middleware built for the tenant + * it ALREADY KNEW when it ran. It knows one only when an earlier middleware + * resolved it: a tenant JWT, a tenant slug in the path, an inspection id in a + * public client path, or the fixed tenant of a single-tenant deployment. A + * route that learns its tenant INSIDE the handler — a public form naming its + * company in the body, a token naming its tenant when redeemed, a global agent + * session — gets the platform-default service there instead, and its mail goes + * out without the tenant's sender name, Reply-To, brand or provider, unmetered. + * Nothing fails when that happens; the mail is simply in the wrong voice. + * + * So `services.email` may be used only in files listed in + * `scripts/tenant-email-allowlist.json`, each with the reason its tenant is + * resolved before the DI middleware. Everything else asks explicitly: + * `services.emailFor(tenantId)` for a send that speaks for a tenant, + * `services.platformEmail()` for one that speaks for the deployment. + * + * Every run prints the number of uses found, how many are exempt, how many are + * violations and how many allow-list entries no longer match — a run that + * finds nothing says so as a number, because a scanner that has stopped + * matching reports the same silence as a clean tree. + * + * node scripts/check-tenant-email-resolution.mjs # gate + * node scripts/check-tenant-email-resolution.mjs --update # prune stale entries (never adds) + * + * console.* is intentional — build script, not server code. + */ +import { readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +/** + * Patterns that read the DI-built service. `emailFor` / `platformEmail` are + * excluded by the trailing identifier guard. + */ +const USE_PATTERNS = [ + // c.var.services.email / services?.email / services.email.sendX(...) + /\bservices\s*\??\.\s*email(?![\w$])/g, + // c.get('services').email + /\bget\(\s*['"]services['"]\s*\)\s*\??\.\s*email(?![\w$])/g, + // services['email'] + /\bservices\s*\[\s*['"]email['"]\s*\]/g, + // const { email } = c.var.services / const { email: mailer, ... } = c.get('services') + /\{[^{}]*\bemail\b[^{}]*\}\s*=\s*(?:[\w$.]*\.)?(?:services\b|get\(\s*['"]services['"]\s*\))/g, +]; + +/** Blank out comments, keeping offsets, so prose that names the API is not a use. */ +export function stripComments(source) { + return source + .replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' ')) + .replace(/(^|[^:])\/\/.*$/gm, (m, p1) => p1 + ' '.repeat(m.length - p1.length)); +} + +/** Every use of the DI-built service in one source file: `[{ line, snippet }]`. */ +export function findServicesEmailUses(source) { + const code = stripComments(source); + const hits = []; + for (const re of USE_PATTERNS) { + for (const m of code.matchAll(re)) { + hits.push({ + index: m.index, + line: code.slice(0, m.index).split('\n').length, + snippet: m[0].replace(/\s+/g, ' ').slice(0, 80), + }); + } + } + return hits.sort((a, b) => a.index - b.index).map(({ line, snippet }) => ({ line, snippet })); +} + +/** + * Judge a scan against the allow-list. + * + * @param {Map>} usesByFile + * @param {Record} allowlist file -> reason its tenant is resolved before DI + */ +export function evaluate(usesByFile, allowlist) { + let matches = 0; + let exempt = 0; + const violations = []; + for (const [file, uses] of usesByFile) { + matches += uses.length; + if (Object.hasOwn(allowlist, file)) { + exempt += uses.length; + } else { + for (const use of uses) violations.push({ file, ...use }); + } + } + const stale = Object.keys(allowlist).filter((file) => (usesByFile.get(file)?.length ?? 0) === 0).sort(); + return { matches, exempt, violations, stale }; +} + +// --------------------------------------------------------------------------- +// Main scan (only when executed directly or by run-gates, not when imported by a spec) +// --------------------------------------------------------------------------- +const ROOT = new URL('..', import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1'); +const SERVER = join(ROOT, 'server'); +const ALLOWLIST = join(ROOT, 'scripts', 'tenant-email-allowlist.json'); + +function walk(dir, out = []) { + for (const entry of readdirSync(dir)) { + const p = join(dir, entry); + if (statSync(p).isDirectory()) { + if (entry !== 'node_modules') walk(p, out); + } else if (/\.tsx?$/.test(entry) && !/\.(spec|test)\.tsx?$/.test(entry) && !entry.endsWith('.d.ts')) { + out.push(p); + } + } + return out; +} + +const _scriptPath = new URL(import.meta.url).pathname.replace(/^\/([A-Za-z]:)/, '$1').toLowerCase(); +const _argv1 = (process.argv[1] ?? '').replace(/\\/g, '/').toLowerCase(); +if (_scriptPath === _argv1 || _argv1.endsWith('/check-tenant-email-resolution.mjs')) { + const files = walk(SERVER); + if (files.length === 0) { + console.error('Tenant email resolution gate: scanned 0 files under server/ — the walk is broken, not the tree.'); + process.exit(1); + } + /** @type {Map>} */ + const usesByFile = new Map(); + for (const file of files) { + const uses = findServicesEmailUses(readFileSync(file, 'utf8')); + if (uses.length) usesByFile.set(relative(ROOT, file).replace(/\\/g, '/'), uses); + } + const allowlist = JSON.parse(readFileSync(ALLOWLIST, 'utf8')); + const { matches, exempt, violations, stale } = evaluate(usesByFile, allowlist); + + if (process.argv.includes('--update')) { + const kept = Object.fromEntries(Object.entries(allowlist).filter(([file]) => !stale.includes(file))); + writeFileSync(ALLOWLIST, JSON.stringify(kept, null, 2) + '\n'); + console.log(`Tenant email allow-list: pruned ${stale.length} stale entr${stale.length === 1 ? 'y' : 'ies'}; ${Object.keys(kept).length} kept. Entries are never ADDED by --update.`); + process.exit(0); + } + + console.log( + `Tenant email resolution: ${matches} use(s) of services.email in ${usesByFile.size} of ${files.length} server files — ` + + `${exempt} exempt (${Object.keys(allowlist).length} allow-listed files), ${violations.length} violation(s), ` + + `${stale.length} stale allow-list entr${stale.length === 1 ? 'y' : 'ies'}.`, + ); + if (stale.length) { + console.log(' Allow-listed files that no longer use services.email (run with --update to prune):'); + for (const file of stale) console.log(` ${file}`); + } + if (violations.length) { + console.error('\nTenant email resolution gate FAILED — services.email used where the tenant may be unknown to DI:\n'); + for (const v of violations) console.error(` ${v.file}:${v.line} ${v.snippet}`); + console.error(` + A route that learns its tenant in the handler must ask for that tenant's service: + const email = await c.var.services.emailFor(tenantId); + A send that speaks for the deployment (no company) must say so: + c.var.services.platformEmail() + Only if this file's tenant is resolved BEFORE the DI middleware on every path + (tenant JWT, tenant slug in the path, fixed tenant), add it to + scripts/tenant-email-allowlist.json with that reason. +`); + process.exit(1); + } +} diff --git a/scripts/lib/gate-registry.mjs b/scripts/lib/gate-registry.mjs index dc55615b5..ae96c1ef3 100644 --- a/scripts/lib/gate-registry.mjs +++ b/scripts/lib/gate-registry.mjs @@ -118,6 +118,9 @@ export const SCRIPT_GATES = [ // already true of, are in check-line-endings.mjs's header. Cost measured // against `rawnul` beside it: 310 ms vs 272 ms over 4002 files. { key: 'eol', label: 'LF line endings (no CRLF, no lone CR)', script: 'check-line-endings.mjs', fix: 'npm run lint:eol', rung: PRECOMMIT }, + // A send in the wrong tenant's voice fails nothing and reads fine — only its + // author knows the route resolves its tenant in the handler. One regex walk. + { key: 'tenantemail', label: 'Tenant email resolution', script: 'check-tenant-email-resolution.mjs', fix: 'npm run lint:tenant-email', rung: PRECOMMIT }, // Belongs at pre-commit rather than CI: what it catches is a CAPABILITY // being added -- a money column, a money field on the inspection record, a // money input on a new screen. By the time CI sees one it is written and diff --git a/scripts/tenant-email-allowlist.json b/scripts/tenant-email-allowlist.json new file mode 100644 index 000000000..325ac69f4 --- /dev/null +++ b/scripts/tenant-email-allowlist.json @@ -0,0 +1,14 @@ +{ + "server/api/admin/admin-data-import.ts": "Mounted under /api/admin: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/admin/admin-data.ts": "Mounted under /api/admin: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/admin/admin-esign.ts": "Mounted under /api/admin: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/auth.ts": "Password reset only: refused with 410 in multi-tenant mode, so it only sends where the fixed tenant is resolved before DI.", + "server/api/inspections/report-delivery.ts": "Mounted under /api/inspections: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/invoices.ts": "Mounted under /api/invoices: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/messages-threads.ts": "Mounted under /api/messages: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/messages.ts": "Inspector routes are tenant-JWT authenticated; the client route is /api/public/inspections/:id/..., whose tenant the inspection-id resolver sets before DI.", + "server/api/migration-intake.ts": "Mounted under /api/imports: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/api/portal.ts": "Mounted under /api/portal/:tenant: the tenant slug in the path is resolved before DI.", + "server/api/team.ts": "Mounted under /api/team: tenant-JWT authenticated, tenant resolved by the JWT middleware before DI.", + "server/lib/agreement-send.ts": "Called only from tenant-JWT routes (admin agreements, inspection agreements), whose tenant is resolved before DI." +} diff --git a/tests/unit/tooling/gate-registry.spec.ts b/tests/unit/tooling/gate-registry.spec.ts index d5c2cf96e..ab26d2dfa 100644 --- a/tests/unit/tooling/gate-registry.spec.ts +++ b/tests/unit/tooling/gate-registry.spec.ts @@ -291,6 +291,12 @@ describe('gate registry', () => { // (310 ms over 4,002), both of which are on this rung. 'agentterms', 'agenttermsselftest', + // A send resolved for the wrong tenant fails no test, type-check or + // build: the mail simply goes out in the platform's voice. Only the + // author of the new send site knows whether its route resolves the + // tenant in the handler, so the question is asked at the keystroke. + // One regex walk of server/**/*.ts, beside `rawnul` in cost. + 'tenantemail', ].sort(); const actual = [...SCRIPT_GATES, DUP_GATE].filter((g) => g.rung === PRECOMMIT).map((g) => g.key).sort(); expect(actual).toEqual(EXPECTED_PRECOMMIT); diff --git a/tests/unit/tooling/tenant-email-resolution-gate.spec.ts b/tests/unit/tooling/tenant-email-resolution-gate.spec.ts new file mode 100644 index 000000000..3df11a229 --- /dev/null +++ b/tests/unit/tooling/tenant-email-resolution-gate.spec.ts @@ -0,0 +1,89 @@ +/** + * Unit tests for the tenant email resolution gate + * (`scripts/check-tenant-email-resolution.mjs`). + * + * The gate allows `services.email` — the service DI built for the tenant it + * already knew — only in allow-listed files whose tenant is resolved before the + * DI middleware. These pin both directions over literal fixtures: a violation + * is found, a clean file is clean, an allow-listed use is counted as exempt + * (not as nothing), and an allow-list entry that no longer matches is stale. + */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import path from 'node:path'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); + +interface Use { line: number; snippet: string } +interface Verdict { matches: number; exempt: number; violations: Array; stale: string[] } + +let findServicesEmailUses: (source: string) => Use[]; +let evaluate: (usesByFile: Map, allowlist: Record) => Verdict; + +beforeAll(async () => { + const scriptPath = path.resolve(HERE, '../../../scripts/check-tenant-email-resolution.mjs'); + ({ findServicesEmailUses, evaluate } = await import(/* @vite-ignore */ pathToFileURL(scriptPath).href)); +}); + +const VIOLATING = ` +export async function confirm(c) { + const svc = c.var.services.email; + await c.var.services.email.sendBookingConfirmation(to); + const { email } = c.var.services; + const other = c.get('services').email; +}`; + +const CLEAN = ` +// services.email is not used here; the comment must not count. +/* nor here: c.var.services.email */ +export async function confirm(c, tenantId) { + const email = await c.var.services.emailFor(tenantId); + await c.var.services.platformEmail().sendAgentLoginLink(to, url); + await email.sendBookingConfirmation(to); + const user = { email: 'a@b.test' }; +}`; + +describe('findServicesEmailUses', () => { + it('finds every shape of reading the DI-built service', () => { + const uses = findServicesEmailUses(VIOLATING); + expect(uses.map((u) => u.line)).toEqual([3, 4, 5, 6]); + }); + + it('finds nothing in a file that uses emailFor / platformEmail, or only mentions the API in comments', () => { + expect(findServicesEmailUses(CLEAN)).toEqual([]); + }); +}); + +describe('evaluate', () => { + it('FAILS: a use outside the allow-list is a violation, and is counted as a match', () => { + const verdict = evaluate(new Map([['server/api/public-thing.ts', findServicesEmailUses(VIOLATING)]]), {}); + expect(verdict.matches).toBe(4); + expect(verdict.exempt).toBe(0); + expect(verdict.violations.map((v) => `${v.file}:${v.line}`)).toEqual([ + 'server/api/public-thing.ts:3', 'server/api/public-thing.ts:4', + 'server/api/public-thing.ts:5', 'server/api/public-thing.ts:6', + ]); + }); + + it('PASSES: a clean tree has zero matches, zero violations — reported as numbers', () => { + const verdict = evaluate(new Map(), {}); + expect(verdict).toEqual({ matches: 0, exempt: 0, violations: [], stale: [] }); + }); + + it('an allow-listed file is EXEMPT, and its uses are still counted', () => { + const verdict = evaluate( + new Map([['server/api/team.ts', findServicesEmailUses(VIOLATING)]]), + { 'server/api/team.ts': 'tenant JWT' }, + ); + expect(verdict).toMatchObject({ matches: 4, exempt: 4, violations: [], stale: [] }); + }); + + it('an allow-list entry whose file no longer uses services.email is reported as stale', () => { + const verdict = evaluate( + new Map([['server/api/team.ts', findServicesEmailUses(VIOLATING)]]), + { 'server/api/team.ts': 'tenant JWT', 'server/api/migrated.ts': 'was tenant JWT' }, + ); + expect(verdict.stale).toEqual(['server/api/migrated.ts']); + expect(verdict.violations).toEqual([]); + }); +}); From 28d05e64580635d1d34714bff1ff10cb621afb37 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 03:22:26 -0400 Subject: [PATCH 4/5] fix(workflow): send the signed-evidence email when only a tenant provider 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 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- server/workflows/sign-completion-email.ts | 69 +++++++++++ server/workflows/sign-completion-workflow.ts | 48 ++------ .../agreements/sign-completion-email.spec.ts | 110 ++++++++++++++++++ 3 files changed, 186 insertions(+), 41 deletions(-) create mode 100644 server/workflows/sign-completion-email.ts create mode 100644 tests/unit/agreements/sign-completion-email.spec.ts diff --git a/server/workflows/sign-completion-email.ts b/server/workflows/sign-completion-email.ts new file mode 100644 index 000000000..4c147af83 --- /dev/null +++ b/server/workflows/sign-completion-email.ts @@ -0,0 +1,69 @@ +import { drizzle } from 'drizzle-orm/d1'; +import { eq } from 'drizzle-orm'; +import type { AppEnv } from '../types/hono'; +import * as schema from '../lib/db/schema'; +import { envelopeVerifyUrl } from '../lib/agreement-verify-url'; +import { buildTenantEmailService } from '../lib/email/build-email-service'; +import { getDeploymentProfile } from '../lib/deployment-profile'; +import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; +import { tenantAiCapsLoader } from '../features/plan-quota/ai-caps'; + +export interface SignedEvidenceEmailInput { + requestId: string; + signedPdfR2Key: string; + evidenceZipR2Key: string; + /** Public origin the verify link is built on. */ + baseUrl: string; +} + +/** + * The sign-completion workflow's last step: email the client the signed PDF + * and the evidence pack. Split out of the workflow so it can be exercised + * without the Workflows runtime. + * + * Returns what it did. Throws on a send failure; the workflow step catches. + */ +export async function sendSignedEvidenceEmail(env: AppEnv, input: SignedEvidenceEmailInput): Promise<'sent' | 'skipped'> { + const { requestId } = input; + if (!env.PHOTOS) return 'skipped'; + const db = drizzle(env.DB, { schema }); + const req = await db.select().from(schema.agreementRequests) + .where(eq(schema.agreementRequests.id, requestId)).get(); + if (!req) return 'skipped'; + // B-13: resolve the tenant's sender identity + branded renderer + // (own/platform provider, display name, reply-to, template overrides) + // even though Workflows run outside diMiddleware. + // Free-tier pre-flight (2026-07): Workflows have no Hono context, so + // build the guard from the pure deployment profile + a one-shot tier + // lookup (mirrors PlanQuotaGuard.consumeInspection's own pattern). + const profile = getDeploymentProfile(env); + const quotaGuard = profile.hasUsageQuota + ? new PlanQuotaGuard(env.DB, { enforced: true, billingPortalUrl: profile.billingPortalUrl, aiCaps: tenantAiCapsLoader(env.DB) }) + : undefined; + const tenantTier = quotaGuard ? await readTenantTier(env.DB, req.tenantId) : undefined; + const email = await buildTenantEmailService(env, req.tenantId, quotaGuard, tenantTier); + // Readiness is the RESOLVED service's, not the deployment's: its key is the + // tenant's own provider credential when that is configured and complete, + // the platform key otherwise. Empty only when neither exists — then there + // is nobody to send through, so skip before reading the artifacts. + if (!email.apiKey) return 'skipped'; + const [signedObj, evidenceObj] = await Promise.all([ + env.PHOTOS.get(input.signedPdfR2Key), + env.PHOTOS.get(input.evidenceZipR2Key), + ]); + if (!signedObj || !evidenceObj) return 'skipped'; + const signedBytes = new Uint8Array(await new Response(signedObj.body).arrayBuffer()); + const evidenceBytes = new Uint8Array(await new Response(evidenceObj.body).arrayBuffer()); + const verifyUrl = req.verificationToken + ? `${input.baseUrl}/v/${req.verificationToken}` + : envelopeVerifyUrl(input.baseUrl, requestId); + await email.sendEvidencePack({ + to: req.clientEmail, + clientName: req.clientName ?? 'Customer', + envelopeId: requestId, + verifyUrl, + signedPdfBytes: signedBytes, + evidenceZipBytes: evidenceBytes, + }); + return 'sent'; +} diff --git a/server/workflows/sign-completion-workflow.ts b/server/workflows/sign-completion-workflow.ts index 23f286fa8..0857dc4bd 100644 --- a/server/workflows/sign-completion-workflow.ts +++ b/server/workflows/sign-completion-workflow.ts @@ -4,12 +4,8 @@ import type { AppEnv } from '../types/hono'; import { SigningKeyService } from '../services/signing-key.service'; import { AuditLogService } from '../services/audit-log.service'; import { m2mAgreementRenderUrl } from '../lib/public-urls'; -import { envelopeVerifyUrl } from '../lib/agreement-verify-url'; import { buildEvidencePack } from '../services/evidence-pack.service'; -import { buildTenantEmailService } from '../lib/email/build-email-service'; -import { getDeploymentProfile } from '../lib/deployment-profile'; -import { PlanQuotaGuard, readTenantTier } from '../features/plan-quota/guard'; -import { tenantAiCapsLoader } from '../features/plan-quota/ai-caps'; +import { sendSignedEvidenceEmail } from './sign-completion-email'; import { drizzle, type DrizzleD1Database } from 'drizzle-orm/d1'; import { and, eq } from 'drizzle-orm'; import * as schema from '../lib/db/schema'; @@ -236,7 +232,7 @@ export class SignCompletionWorkflow extends WorkflowEntrypoint { try { // The signed agreement is the must-deliver artifact — never @@ -247,41 +243,11 @@ export class SignCompletionWorkflow extends WorkflowEntrypoint Promise<{ body: Uint8Array }>; +function photos(): { bucket: R2Bucket; get: Mock } { + const get = vi.fn(async () => ({ body: new Uint8Array([37, 80, 68, 70]) })); + return { bucket: { get } as unknown as R2Bucket, get }; +} + +async function seedEnvelope(h: Harness): Promise { + await h.db.insert(schema.inspections).values({ + id: INSPECTION_ID, tenantId: TENANT_ID, inspectorId: INSPECTOR_ID, propertyAddress: '12 Lighthouse Road', + date: '2026-06-01', status: 'requested', paymentStatus: 'unpaid', price: 0, + agreementRequired: true, paymentRequired: false, createdAt: new Date(), + }); + await h.db.insert(schema.agreements).values({ + id: AGREEMENT_ID, tenantId: TENANT_ID, name: 'Standard Agreement', content: 'Text', version: 1, createdAt: new Date(), + }); + const envelope = await new AgreementService(h.env.DB, { jwtSecret: h.env.JWT_SECRET }).findOrCreate(TENANT_ID, INSPECTION_ID, { + signers: [{ name: 'Casey Client', email: 'casey@client.test', role: 'client' }], + completionPolicy: 'one', + }); + return envelope.requestId; +} + +/** The tenant sends through its OWN provider account. */ +async function useOwnProvider(h: Harness): Promise { + await h.db.update(schema.tenantConfigs).set({ + emailMode: 'own', + senderEmail: TENANT_SENDER, + secretsEnc: await encryptSecrets({ RESEND_API_KEY: TENANT_KEY }, h.env.JWT_SECRET), + }).where(eq(schema.tenantConfigs.tenantId, TENANT_ID)); +} + +function withoutPlatformKey(env: Env, bucket: R2Bucket): Env { + return { ...env, RESEND_API_KEY: undefined, PHOTOS: bucket } as unknown as Env; +} + +const input = (requestId: string) => ({ + requestId, signedPdfR2Key: 'signed.pdf', evidenceZipR2Key: 'evidence.zip', baseUrl: 'https://app.platform.test', +}); + +describe('sendSignedEvidenceEmail', () => { + let h: Harness; + afterEach(() => h.close()); + + it('no platform key, tenant has its own provider: sends through the tenant provider', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + const requestId = await seedEnvelope(h); + await useOwnProvider(h); + const r2 = photos(); + + const outcome = await sendSignedEvidenceEmail(withoutPlatformKey(h.env, r2.bucket), input(requestId)); + + expect(outcome).toBe('sent'); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.to).toEqual(['casey@client.test']); + expect(h.sent[0]?.from).toBe(`${TENANT_IDENTITY.senderDisplayName} <${TENANT_SENDER}>`); + expect(h.sent[0]?.authorization).toBe(`Bearer ${TENANT_KEY}`); + }); + + it('no platform key and no tenant provider: skips, and reads nothing from storage', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + const requestId = await seedEnvelope(h); + const r2 = photos(); + + const outcome = await sendSignedEvidenceEmail(withoutPlatformKey(h.env, r2.bucket), input(requestId)); + + expect(outcome).toBe('skipped'); + expect(h.sent).toHaveLength(0); + expect(r2.get).not.toHaveBeenCalled(); + }); + + it('platform key present: sends through the platform provider, as before', { timeout: 60_000 }, async () => { + h = await createHarness('standalone'); + const requestId = await seedEnvelope(h); + const r2 = photos(); + + const outcome = await sendSignedEvidenceEmail({ ...h.env, PHOTOS: r2.bucket } as Env, input(requestId)); + + expect(outcome).toBe('sent'); + expect(h.sent).toHaveLength(1); + expect(h.sent[0]?.authorization).toBe(`Bearer ${PLATFORM_IDENTITY.resendKey}`); + }); +}); From 1992c509554b11d6b7311f33424e5afb6db501b7 Mon Sep 17 00:00:00 2001 From: important-new <75075215+important-new@users.noreply.github.com> Date: Sun, 11 Oct 2026 04:07:16 -0400 Subject: [PATCH 5/5] test(web): make the SMS consent date assertion independent of the viewer'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 Claude-Session: https://claude.ai/code/session_01FDq9K9niRKMErgty3ca3DD --- app/components/notifications/SmsConsentBlock.test.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/components/notifications/SmsConsentBlock.test.tsx b/app/components/notifications/SmsConsentBlock.test.tsx index fd34f2364..610416f97 100644 --- a/app/components/notifications/SmsConsentBlock.test.tsx +++ b/app/components/notifications/SmsConsentBlock.test.tsx @@ -12,8 +12,10 @@ import { render } from "@testing-library/react"; import { SmsConsentBlock, type SmsConsent } from "./SmsConsentBlock"; const DISCLOSURE = { version: 3, text: "Message and data rates may apply." }; +// Noon UTC, not midnight: midnight is still the 11th for every viewer west of Greenwich, so the +// assertion below passed only on UTC and east of it. Noon is the 12th from UTC-11 to UTC+11. const base: SmsConsent = { - phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T00:00:00.000Z", + phone: "+1 555 000 1111", state: "granted", at: "2026-06-12T12:00:00.000Z", capturedVia: "booking_form", disclosure: DISCLOSURE, mode: "express", };