Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion app/components/notifications/SmsConsentBlock.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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",
};

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
160 changes: 160 additions & 0 deletions scripts/check-tenant-email-resolution.mjs
Original file line number Diff line number Diff line change
@@ -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<string, Array<{ line: number, snippet: string }>>} usesByFile
* @param {Record<string, string>} 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<string, Array<{ line: number, snippet: string }>>} */
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);
}
}
3 changes: 3 additions & 0 deletions scripts/lib/gate-registry.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions scripts/tenant-email-allowlist.json
Original file line number Diff line number Diff line change
@@ -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."
}
3 changes: 2 additions & 1 deletion server/api/agent/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
3 changes: 2 additions & 1 deletion server/api/agent/magic-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down
2 changes: 1 addition & 1 deletion server/api/bookings/agreement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}

Expand Down
1 change: 1 addition & 0 deletions server/api/inspections/agreement-sign.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ agreementSignRoutes.post('/:id/sign', async (c) => {
signerName: signer.name,
inspectionId: result.inspectionId,
requestId: result.requestId,
tenantId: result.tenantId,
});
}

Expand Down
21 changes: 18 additions & 3 deletions server/api/integrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 4 additions & 2 deletions server/api/repair-builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand All @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions server/lib/email/build-email-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;

Expand Down
14 changes: 13 additions & 1 deletion server/lib/email/outbound-cooling-window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,8 +112,20 @@ export function isAccountEmailClass(classId: string | undefined): boolean {
export function coolingWindowApplies(input: {
profile: Pick<DeploymentProfile, 'mode'>;
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. */
Expand Down
Loading
Loading