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", }; 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/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/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/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/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/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/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 { 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/agreements/sign-completion-email.spec.ts b/tests/unit/agreements/sign-completion-email.spec.ts new file mode 100644 index 000000000..9750a1989 --- /dev/null +++ b/tests/unit/agreements/sign-completion-email.spec.ts @@ -0,0 +1,110 @@ +/** + * The sign-completion workflow's evidence email goes out whenever SOME email + * provider is usable — the deployment's key OR the tenant's own provider. + * + * It used to return early whenever the deployment had no platform key, so a + * deployment whose companies all send through their own provider never + * delivered the signed agreement and evidence pack at all. Nothing is sent — + * and nothing is read from storage — only when neither is available. + */ +import { describe, it, expect, afterEach, vi, type Mock } from 'vitest'; +import { eq } from 'drizzle-orm'; +import * as schema from '../../../server/lib/db/schema'; +import { encryptSecrets } from '../../../server/lib/config-crypto'; +import { AgreementService } from '../../../server/services/agreement.service'; +import { sendSignedEvidenceEmail } from '../../../server/workflows/sign-completion-email'; +import type { HonoConfig } from '../../../server/types/hono'; +import { + createHarness, INSPECTOR_ID, PLATFORM_IDENTITY, TENANT_ID, TENANT_IDENTITY, type Harness, +} from '../helpers/tenant-email-harness'; + +const INSPECTION_ID = '66666666-0000-4000-8000-000000000001'; +const AGREEMENT_ID = '66666666-0000-4000-8000-000000000002'; +const TENANT_SENDER = 'mail@harbor-light.test'; +const TENANT_KEY = 're_tenant_own_key'; + +type Env = HonoConfig['Bindings']; + +/** An R2 double holding the two artifacts; `get` is a spy so reads can be counted. */ +type R2Get = (key: string) => 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}`); + }); +}); 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/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/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/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); + }); +}); 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/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([]); + }); +}); 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);