create-safest-tools 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,7 +18,7 @@ npx create-safest-tools safest-resolve \
18
18
  --email-from reports@example.com
19
19
  ```
20
20
 
21
- The `--email-from` address is a real, receivable mailbox used for reporter receipts, follow-ups, outcomes, and replies. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address.
21
+ The `--email-from` address is a real, receivable mailbox used for receipts, follow-ups, outcomes, and replies from reporters or affected users. Use a short base address such as `reports@example.com` on a domain onboarded under Cloudflare Email Service → Email Sending, then enable Email Routing for that domain. Setup creates an exact Worker route for this address, enables plus-addressing for signed case replies, and does not enable catch-all mail. Account verification, invitations, and password recovery use a distinct `accounts@` address on the same domain by default; pass `--auth-email-from` to choose another address. Every customer-facing email automatically uses the organization name, logo, colors, type, and spacing saved under Configuration → Branding.
22
22
 
23
23
  The command creates a local project and prints a read-only infrastructure plan. It does not change Cloudflare unless `--deploy` is supplied or the generated project’s `npm run setup` command is run and explicitly confirmed.
24
24
 
@@ -44,7 +44,7 @@ npm run setup:plan
44
44
  npm run setup
45
45
  ```
46
46
 
47
- `setup:plan` lists exact resource names and the reporter email route, then exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route.
47
+ `setup:plan` lists exact resource names and the reporter email route, then exits without modifying Cloudflare. `setup` opens Wrangler's Cloudflare login when needed, lets you choose the owning account, and verifies Workers Paid from the account's Workers usage model. Standard accounts need no separate billing token; only legacy or ambiguous account models use the temporary Billing Read fallback. It then guides Google, GitHub, and Cloudflare OAuth configuration with exact callback URLs and masked secret input. Nothing is provisioned until the exact `DEPLOY <installation-id>` confirmation. After confirmation it verifies Email Routing, enables subaddressing, and deploys only the exact reporter-address route. If Cloudflare interrupts the deployment, run `npm run setup` again; it detects the unfinished installation, reuses the saved authentication choices and exact resources, suppresses expected already-exists errors, and continues through the owner-link step.
48
48
 
49
49
  A new installation applies one current D1 schema baseline. Later releases add only forward-compatible upgrade migrations.
50
50
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-safest-tools",
3
- "version": "0.5.0",
3
+ "version": "0.5.2",
4
4
  "description": "Create customer-owned abuse-reporting infrastructure on Cloudflare",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
package/src/config.mjs CHANGED
@@ -158,7 +158,7 @@ export function buildWranglerConfiguration(config) {
158
158
  OPERATOR_NOTIFICATION_WEBHOOK_URL: "",
159
159
  AUTH_EMAIL_FROM: email.auth,
160
160
  REPORTER_EMAIL_FROM: email.reporter,
161
- REPORTER_EMAIL_SUBJECT_PREFIX: "Safest report update",
161
+ REPORTER_EMAIL_SUBJECT_PREFIX: "",
162
162
  },
163
163
  triggers: { crons: ["*/5 * * * *"] },
164
164
  };
@@ -10,7 +10,7 @@ Before deployment:
10
10
 
11
11
  1. Review `reports.config.json` and `npm run setup:plan`.
12
12
  2. Under Cloudflare Email Service → Email Sending, onboard the configured sender domain. Enable Email Routing for that domain and use a real reporter mailbox such as `reports@example.com`; setup creates only that exact Worker route, enables plus-addressing for signed case replies, and never enables catch-all routing. Reporter updates and account mail use separate sender addresses and sender-restricted bindings.
13
- 3. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs.
13
+ 3. Run `npm run setup`. The guided setup signs in through Wrangler and verifies Workers Paid from the account's Workers usage model before provisioning. Standard accounts need no separate billing token; only legacy or ambiguous models use the temporary Billing Read fallback. Setup then creates owner-only local secrets and walks through optional Google, GitHub, and Cloudflare OAuth credentials with exact callback URLs. If Cloudflare interrupts deployment, run the same command again: setup detects the unfinished installation, reuses the saved choices and exact resources, skips the repeated OAuth questions, and continues to the owner link.
14
14
  4. Type the exact installation confirmation when setup requests it. After deployment, setup prints a 256-bit, single-use owner link that expires after 15 minutes and is never written to disk.
15
15
  5. Open the owner link, create the Safest owner account, then invite administrators and analysts from People. Nobody needs a Cloudflare account to sign in. Names and workspace roles are always displayed separately.
16
16
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "safest-resolve-installation",
3
- "version": "0.5.0",
4
- "safestToolsVersion": "0.5.0-resolve",
3
+ "version": "0.5.2",
4
+ "safestToolsVersion": "0.5.2-resolve",
5
5
  "private": true,
6
6
  "license": "Apache-2.0",
7
7
  "type": "module",
@@ -46,5 +46,9 @@
46
46
  },
47
47
  "engines": {
48
48
  "node": ">=20.12.0"
49
+ },
50
+ "allowScripts": {
51
+ "esbuild": true,
52
+ "workerd": true
49
53
  }
50
54
  }
@@ -37,36 +37,83 @@ function parseArguments(argv) {
37
37
  return result;
38
38
  }
39
39
 
40
- function runWrangler(args, label) {
40
+ function runWrangler(args, label, { stdin = "inherit" } = {}) {
41
41
  return new Promise((resolvePromise, reject) => {
42
42
  console.log(`\n${label}`);
43
- const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: "inherit" });
43
+ const child = spawn(process.execPath, [wrangler, ...args], {
44
+ cwd: projectRoot,
45
+ env: process.env,
46
+ stdio: [stdin, "inherit", "inherit"],
47
+ });
44
48
  child.once("error", reject);
45
49
  child.once("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
46
50
  });
47
51
  }
48
52
 
53
+ function captureWrangler(args, { stdin = "ignore" } = {}) {
54
+ return new Promise((resolvePromise, reject) => {
55
+ const child = spawn(process.execPath, [wrangler, ...args], {
56
+ cwd: projectRoot,
57
+ env: process.env,
58
+ stdio: [stdin, "pipe", "pipe"],
59
+ });
60
+ let stdout = "";
61
+ let stderr = "";
62
+ child.stdout.on("data", (chunk) => { stdout += chunk; });
63
+ child.stderr.on("data", (chunk) => { stderr += chunk; });
64
+ child.once("error", reject);
65
+ child.once("exit", (code, signal) => resolvePromise({ code, signal, stdout, stderr, output: `${stdout}\n${stderr}` }));
66
+ });
67
+ }
68
+
69
+ function printWranglerOutput({ stdout, stderr }) {
70
+ if (stdout) process.stdout.write(stdout);
71
+ if (stderr) process.stderr.write(stderr);
72
+ }
73
+
49
74
  export function isExistingResourceError(output) {
50
75
  return /already exists|already (?:been )?taken|duplicate resource/iu.test(output);
51
76
  }
52
77
 
53
- function ensureWranglerResource(args, label) {
54
- return new Promise((resolvePromise, reject) => {
55
- console.log(`\n${label}`);
56
- const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: ["inherit", "pipe", "pipe"] });
57
- let output = "";
58
- child.stdout.on("data", (chunk) => { output += chunk; process.stdout.write(chunk); });
59
- child.stderr.on("data", (chunk) => { output += chunk; process.stderr.write(chunk); });
60
- child.once("error", reject);
61
- child.once("exit", (code, signal) => {
62
- if (code === 0) return resolvePromise();
63
- if (isExistingResourceError(output)) {
64
- console.log("Resource already exists; reusing the exact configured name.");
65
- return resolvePromise();
66
- }
67
- reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`));
68
- });
69
- });
78
+ export function isTransientWranglerAuthenticationError(output) {
79
+ return /authentication error[\s\S]{0,200}(?:code\s*:\s*10000|\[code:\s*10000\])/iu.test(output);
80
+ }
81
+
82
+ async function ensureWranglerResource(args, label) {
83
+ console.log(`\n${label}`);
84
+ const result = await captureWrangler(args);
85
+ if (result.code === 0) {
86
+ console.log("Created the exact configured resource.");
87
+ return;
88
+ }
89
+ if (isExistingResourceError(result.output)) {
90
+ console.log("Already exists; reusing the exact configured resource.");
91
+ return;
92
+ }
93
+ printWranglerOutput(result);
94
+ throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
95
+ }
96
+
97
+ async function deployWithAuthenticationRetry(args, label) {
98
+ console.log(`\n${label}`);
99
+ let result = await captureWrangler(args, { stdin: "inherit" });
100
+ if (result.code === 0) {
101
+ printWranglerOutput(result);
102
+ return;
103
+ }
104
+ if (!isTransientWranglerAuthenticationError(result.output)) {
105
+ printWranglerOutput(result);
106
+ throw new Error(`${label} failed${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
107
+ }
108
+
109
+ console.log("Cloudflare rejected the upload token. Refreshing Wrangler authentication and retrying once.");
110
+ await runWrangler(["whoami"], "Refresh Cloudflare authentication");
111
+ console.log(`\n${label} (automatic retry 1 of 1)`);
112
+ result = await captureWrangler(args, { stdin: "inherit" });
113
+ printWranglerOutput(result);
114
+ if (result.code !== 0) {
115
+ throw new Error(`${label} failed after one automatic authentication retry${result.signal ? ` with signal ${result.signal}` : ` with exit code ${result.code}`}`);
116
+ }
70
117
  }
71
118
 
72
119
  async function validateSecrets(path, config) {
@@ -88,6 +135,23 @@ async function hasDatabaseId() {
88
135
  return typeof config?.d1_databases?.[0]?.database_id === "string" && config.d1_databases[0].database_id.length > 0;
89
136
  }
90
137
 
138
+ async function fileExists(path) {
139
+ try {
140
+ await readFile(path);
141
+ return true;
142
+ } catch (error) {
143
+ if (error?.code === "ENOENT") return false;
144
+ throw error;
145
+ }
146
+ }
147
+
148
+ export function setupInstallationState({ databaseConfigured, installationRecorded }) {
149
+ if (installationRecorded && !databaseConfigured) return "inconsistent";
150
+ if (installationRecorded) return "installed";
151
+ if (databaseConfigured) return "resume";
152
+ return "fresh";
153
+ }
154
+
91
155
  async function confirmation(installationId, yes) {
92
156
  if (yes) return;
93
157
  if (!process.stdin.isTTY) throw new Error("interactive confirmation is required; use --yes only after reviewing setup:plan");
@@ -105,6 +169,20 @@ export async function deployReports(argv = process.argv.slice(2)) {
105
169
  console.log(JSON.stringify({ action: options.upgrade ? "upgrade" : "install", ...plan }, null, 2));
106
170
  if (options.planOnly) return { status: "planned", plan };
107
171
  if (!plan.ready) throw new Error(plan.blockers.join(" "));
172
+ const installationPath = resolve(projectRoot, ".safest/installation.json");
173
+ const databaseConfigured = await hasDatabaseId();
174
+ const installationRecorded = await fileExists(installationPath);
175
+ const setupState = setupInstallationState({ databaseConfigured, installationRecorded });
176
+ if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
177
+ if (!options.upgrade && setupState === "installed") {
178
+ throw new Error("setup is already complete; use npm run owner:setup for a new owner link or npm run upgrade for an existing installation");
179
+ }
180
+ if (!options.upgrade && setupState === "inconsistent") {
181
+ throw new Error("the installation receipt exists but wrangler.jsonc has no D1 database_id; restore the database configuration before continuing");
182
+ }
183
+ if (!options.upgrade && setupState === "resume") {
184
+ console.log("\nInterrupted installation detected. Reusing the saved authentication choices and exact Cloudflare resources, then continuing setup.");
185
+ }
108
186
  const interactive = process.stdin.isTTY && !options.yes;
109
187
  const authentication = await ensureWranglerAuthentication({ wrangler, projectRoot, interactive });
110
188
  const accountSelection = await chooseCloudflareAccount({
@@ -127,7 +205,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
127
205
  await initializeSecrets(secretsPath);
128
206
  console.log(`Created ${secretsPath} with generated owner-only secrets.`);
129
207
  }
130
- if (!options.upgrade) {
208
+ if (!options.upgrade && setupState === "fresh") {
131
209
  const configured = await configureAuthentication({
132
210
  configPath: resolve(projectRoot, options.config),
133
211
  secretsPath,
@@ -144,10 +222,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
144
222
  wranglerToken: authentication.token,
145
223
  });
146
224
  console.log(`\nReporter replies will route through ${reporterRouting.address}; plus-addressing is enabled and no catch-all is created.`);
147
- const databaseConfigured = await hasDatabaseId();
148
- if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
149
- if (!options.upgrade && databaseConfigured) throw new Error("this project already has a D1 database_id; use npm run upgrade instead of setup");
150
- if (!options.upgrade) {
225
+ if (!options.upgrade && setupState === "fresh") {
151
226
  await runWrangler(["d1", "create", config.resources.databaseName, "--binding", "DB", "--update-config"], "Create the customer-owned D1 database");
152
227
  }
153
228
  await ensureWranglerResource(["r2", "bucket", "create", config.resources.profileMediaBucketName], "Ensure the private profile-media R2 bucket exists");
@@ -157,9 +232,13 @@ export async function deployReports(argv = process.argv.slice(2)) {
157
232
  await ensureWranglerResource(["queues", "create", config.resources.reportQueueName], "Ensure the report jobs Queue exists");
158
233
  await ensureWranglerResource(["queues", "create", config.resources.deliveryQueueName], "Ensure the delivery jobs Queue exists");
159
234
  await ensureWranglerResource(["queues", "create", config.resources.operationsDlqName], "Ensure the operations dead-letter Queue exists");
160
- await runWrangler(["d1", "migrations", "apply", "DB", "--remote"], "Apply forward-only report migrations");
161
- await runWrangler(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
162
- const installationPath = resolve(projectRoot, ".safest/installation.json");
235
+ await runWrangler(
236
+ ["d1", "migrations", "apply", "DB", "--remote"],
237
+ "Apply forward-only report migrations",
238
+ { stdin: "ignore" },
239
+ );
240
+ await deployWithAuthenticationRetry(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
241
+ const ownerSetup = options.upgrade ? null : await issueOwnerSetup({ config });
163
242
  await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
164
243
  await writeFile(installationPath, `${JSON.stringify({
165
244
  schemaVersion: 2,
@@ -168,9 +247,9 @@ export async function deployReports(argv = process.argv.slice(2)) {
168
247
  resources: config.resources,
169
248
  ownerEmail: config.owner.email,
170
249
  }, null, 2)}\n`, { mode: 0o600 });
171
- if (!options.upgrade) printOwnerSetup(await issueOwnerSetup({ config }));
250
+ if (ownerSetup) printOwnerSetup(ownerSetup);
172
251
  console.log("\nDeployment finished. Open the one-time owner setup link, run the authenticated setup feature checks until /ready succeeds, invite an administrator and analyst, then verify one server report and Queue consumption before production traffic.");
173
- return { status: "deployed", plan };
252
+ return { status: "deployed", plan, resumed: !options.upgrade && setupState === "resume" };
174
253
  }
175
254
 
176
255
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -3,6 +3,9 @@ import { getMigrations } from "better-auth/db/migration";
3
3
  import { genericOAuth } from "better-auth/plugins";
4
4
  import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
5
5
  import { authenticationEmailBinding } from "./report-email";
6
+ import { renderAuthenticationEmail } from "./report-email-template";
7
+ import type { AuthenticationEmailKind } from "./report-email-template";
8
+ import { loadWorkspaceBrand } from "./workspace-branding";
6
9
 
7
10
  const INVITATION_COOKIE = "safest_invitation";
8
11
  const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
@@ -255,36 +258,41 @@ async function identityMaySignIn(
255
258
  return { error: "account_unavailable", errorDescription: "This Safest Resolve account is unavailable." };
256
259
  }
257
260
 
258
- function escapeHtml(value: string): string {
259
- return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;")
260
- .replaceAll(">", "&gt;").replaceAll('"', "&quot;").replaceAll("'", "&#39;");
261
- }
261
+ type AuthenticationEmailMessage = {
262
+ to: string;
263
+ kind: AuthenticationEmailKind;
264
+ url: string;
265
+ role?: string;
266
+ expiresAt?: string;
267
+ };
262
268
 
263
269
  async function sendAuthEmail(
264
270
  env: BetterAuthEnv,
265
- message: { to: string; subject: string; heading: string; body: string; action: string; url: string },
271
+ message: AuthenticationEmailMessage,
266
272
  ): Promise<void> {
273
+ const brand = await loadWorkspaceBrand(env.DB);
274
+ const rendered = renderAuthenticationEmail({
275
+ brand,
276
+ kind: message.kind,
277
+ url: message.url,
278
+ ...(message.role ? { role: message.role } : {}),
279
+ ...(message.expiresAt ? { expiresAt: message.expiresAt } : {}),
280
+ });
267
281
  const email = authenticationEmailBinding(env);
268
282
  if (localDevelopment(env) && (!email || !env.AUTH_EMAIL_FROM)) {
269
- console.info(`Local authentication email (${message.subject}) for ${message.to}: ${message.url}`);
283
+ console.info(`Local authentication email (${rendered.subject}) for ${message.to}: ${message.url}`);
270
284
  return;
271
285
  }
272
286
  if (!email || !env.AUTH_EMAIL_FROM) {
273
287
  throw new Error("Cloudflare Email Service and AUTH_EMAIL_FROM are required for authentication email.");
274
288
  }
275
- const footer = "If you did not request this message, you can safely ignore it.";
276
- const html = `<!doctype html><html><body style="font-family:system-ui,sans-serif;color:#171717">` +
277
- `<h1 style="font-size:20px">${escapeHtml(message.heading)}</h1>` +
278
- `<p>${escapeHtml(message.body)}</p>` +
279
- `<p><a href="${escapeHtml(message.url)}" style="display:inline-block;padding:10px 16px;` +
280
- `background:#171717;color:#fff;text-decoration:none;border-radius:6px">${escapeHtml(message.action)}</a></p>` +
281
- `<p style="font-size:12px;color:#666">${escapeHtml(footer)}</p></body></html>`;
282
289
  await email.send({
283
- from: { name: "Safest Resolve", email: env.AUTH_EMAIL_FROM },
290
+ from: { name: brand.organization_name, email: env.AUTH_EMAIL_FROM },
284
291
  to: message.to,
285
- subject: message.subject,
286
- text: `${message.heading}\n\n${message.body}\n\n${message.action}: ${message.url}\n\n${footer}`,
287
- html,
292
+ subject: rendered.subject,
293
+ text: rendered.text,
294
+ html: rendered.html,
295
+ headers: { "X-Safest-Email-Type": message.kind },
288
296
  });
289
297
  }
290
298
 
@@ -369,25 +377,24 @@ export async function sendOperatorInvitationEmail(
369
377
  env: BetterAuthEnv,
370
378
  invitation: { email: string; inviteUrl: string; role: string; expiresAt: string },
371
379
  ): Promise<void> {
372
- const role = invitation.role.replaceAll("_", " ");
373
380
  await sendAuthEmail(env, {
374
381
  to: invitation.email,
375
- subject: "You are invited to Safest Resolve",
376
- heading: "Join Safest Resolve",
377
- body: `You were invited as ${role}. This single-use invitation expires ${new Date(invitation.expiresAt).toUTCString()}.`,
378
- action: "Accept invitation",
382
+ kind: "invitation",
379
383
  url: invitation.inviteUrl,
384
+ role: invitation.role,
385
+ expiresAt: invitation.expiresAt,
380
386
  });
381
387
  }
382
388
 
383
- function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
389
+ async function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
384
390
  const secret = authSecret(env);
385
391
  const baseURL = new URL(env.PUBLIC_BASE_URL).origin;
386
392
  const development = localDevelopment(env);
387
393
  const cloudflare = cloudflareProvider(env);
388
394
  const plugins = [genericOAuth({ config: cloudflare ? [cloudflare] : [] })];
395
+ const brand = await loadWorkspaceBrand(env.DB);
389
396
  return betterAuth({
390
- appName: "Safest Resolve",
397
+ appName: brand.organization_name,
391
398
  baseURL,
392
399
  basePath: "/api/auth",
393
400
  trustedOrigins: development ? [baseURL, "http://localhost:*", "http://127.0.0.1:*"] : [baseURL],
@@ -430,10 +437,7 @@ function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
430
437
  const resetUrl = `${baseURL}/#reset=${encodeURIComponent(token)}`;
431
438
  await deliverAuthEmail(env, ctx, {
432
439
  to: user.email,
433
- subject: "Reset your Safest Resolve password",
434
- heading: "Reset your password",
435
- body: "Use this one-time link within 30 minutes. Resetting your password signs out your other sessions.",
436
- action: "Reset password",
440
+ kind: "password_reset",
437
441
  url: resetUrl,
438
442
  });
439
443
  },
@@ -447,10 +451,7 @@ function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
447
451
  if (await ownerSetupAllowsEmail(request, env, user.email)) return;
448
452
  await deliverAuthEmail(env, ctx, {
449
453
  to: user.email,
450
- subject: "Verify your Safest Resolve email",
451
- heading: "Verify your email",
452
- body: "Confirm this address before signing in. This link expires in 24 hours and can be used once.",
453
- action: "Verify email",
454
+ kind: "email_verification",
454
455
  url,
455
456
  });
456
457
  },
@@ -491,7 +492,7 @@ function createBetterAuth(env: BetterAuthEnv, ctx?: ExecutionContext) {
491
492
  const migrationPromises = new WeakMap<object, Promise<void>>();
492
493
 
493
494
  function ensureBetterAuthMigrations(
494
- auth: ReturnType<typeof createBetterAuth>,
495
+ auth: Awaited<ReturnType<typeof createBetterAuth>>,
495
496
  database: D1Database,
496
497
  ): Promise<void> {
497
498
  const key = database as unknown as object;
@@ -512,7 +513,7 @@ export async function handleBetterAuthRequest(
512
513
  env: BetterAuthEnv,
513
514
  ctx: ExecutionContext,
514
515
  ): Promise<Response> {
515
- const auth = createBetterAuth(env, ctx);
516
+ const auth = await createBetterAuth(env, ctx);
516
517
  await ensureBetterAuthMigrations(auth, env.DB);
517
518
  return auth.handler(request);
518
519
  }
@@ -542,7 +543,7 @@ export async function resolveBetterAuthSession(
542
543
  request: Request,
543
544
  env: BetterAuthEnv,
544
545
  ): Promise<BetterAuthSession | null> {
545
- const auth = createBetterAuth(env);
546
+ const auth = await createBetterAuth(env);
546
547
  await ensureBetterAuthMigrations(auth, env.DB);
547
548
  const session = await auth.api.getSession({ headers: request.headers });
548
549
  return session as BetterAuthSession | null;
@@ -552,7 +553,7 @@ export async function revokeBetterAuthSessionsForOperator(
552
553
  env: BetterAuthEnv,
553
554
  operatorUserId: string,
554
555
  ): Promise<number> {
555
- const auth = createBetterAuth(env);
556
+ const auth = await createBetterAuth(env);
556
557
  await ensureBetterAuthMigrations(auth, env.DB);
557
558
  const result = await env.DB.prepare(`
558
559
  DELETE FROM auth_session
@@ -495,6 +495,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
495
495
  fromAddress: from,
496
496
  publicReference: row.reportReference,
497
497
  signingSecret: env.CONTEXT_SIGNING_SECRET,
498
+ audience: row.audience,
498
499
  expiresAt: Math.floor(Date.now() / 1_000) + Math.max(
499
500
  Number.parseInt(env.REPORT_RETENTION_DAYS, 10) || 365,
500
501
  Number.parseInt(env.APPEAL_WINDOW_DAYS, 10) || 30,
@@ -503,7 +504,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
503
504
  ]);
504
505
  const rendered = renderReporterEmail({
505
506
  brand, body: row.body, reportReference: row.reportReference,
506
- templateVersionId: row.templateVersionId, subjectPrefix: prefix,
507
+ templateVersionId: row.templateVersionId, subjectPrefix: prefix, audience: row.audience,
507
508
  });
508
509
  const result = await binding.send({
509
510
  to: email,
@@ -12,6 +12,7 @@ interface ReplyReportRow {
12
12
  state: ReportState;
13
13
  participantId: string;
14
14
  contactCiphertext: string | null;
15
+ audience: "reporter" | "affected_user";
15
16
  }
16
17
 
17
18
  function now(): string {
@@ -102,16 +103,16 @@ export async function receiveReporterEmail(
102
103
  const report = await env.DB.prepare(`
103
104
  SELECT r.id, r.state, p.id AS participantId, p.contact_ciphertext AS contactCiphertext
104
105
  FROM reports r
105
- JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
106
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = ?2
106
107
  WHERE r.public_reference = ?1
107
108
  LIMIT 1
108
- `).bind(claim.publicReference).first<ReplyReportRow>();
109
+ `).bind(claim.publicReference, claim.audience).first<ReplyReportRow>();
109
110
  if (!report?.contactCiphertext) {
110
111
  await recordUnmatchedEmail(env.DB, message, "report_or_reporter_contact_missing");
111
112
  return { reportId: null, stored: false };
112
113
  }
113
114
  const expectedSender = (await decryptReportContact(
114
- env.CREDENTIAL_ENCRYPTION_KEY, report.contactCiphertext, report.id, "reporter",
115
+ env.CREDENTIAL_ENCRYPTION_KEY, report.contactCiphertext, report.id, report.audience,
115
116
  )).trim().toLowerCase();
116
117
  if (!await secretsEqual(message.from.trim().toLowerCase(), expectedSender)) {
117
118
  await recordUnmatchedEmail(env.DB, message, "sender_mismatch");
@@ -145,8 +146,8 @@ export async function receiveReporterEmail(
145
146
  INSERT OR IGNORE INTO case_messages (
146
147
  id, report_id, audience, direction, sender_type, sender_id, body,
147
148
  automated, delivery_state, created_at, idempotency_key, audience_checked_at
148
- ) VALUES (?1, ?2, 'reporter', 'inbound', 'reporter', ?3, ?4, 0, 'not_required', ?5, ?6, ?5)
149
- `).bind(messageId, report.id, report.participantId, body, receivedAt, idempotencyKey),
149
+ ) VALUES (?1, ?2, ?3, 'inbound', ?3, ?4, ?5, 0, 'not_required', ?6, ?7, ?6)
150
+ `).bind(messageId, report.id, report.audience, report.participantId, body, receivedAt, idempotencyKey),
150
151
  env.DB.prepare(`UPDATE reports SET state = ?2, updated_at = ?3 WHERE id = ?1`).bind(report.id, targetState, receivedAt),
151
152
  env.DB.prepare(`
152
153
  INSERT OR IGNORE INTO audit_events (
@@ -1,53 +1,279 @@
1
1
  import type { WorkspaceBrand } from "./workspace-branding";
2
2
 
3
+ export type CaseEmailAudience = "reporter" | "affected_user";
4
+ export type AuthenticationEmailKind = "invitation" | "password_reset" | "email_verification";
5
+
6
+ export type RenderedEmail = {
7
+ subject: string;
8
+ text: string;
9
+ html: string;
10
+ };
11
+
12
+ type Detail = { label: string; value: string };
13
+
3
14
  function escapeHtml(value: string): string {
4
15
  return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
5
16
  .replaceAll('"', "&quot;").replaceAll("'", "&#039;");
6
17
  }
7
18
 
8
- function kind(templateVersionId: string | null): "receipt" | "followup" | "outcome" {
19
+ function messageHtml(value: string): string {
20
+ return escapeHtml(value).replaceAll("\n", "<br />");
21
+ }
22
+
23
+ function channelHost(url: string): string {
24
+ try { return new URL(url).hostname; } catch { return "the workspace address"; }
25
+ }
26
+
27
+ function readableUtc(value: string): string {
28
+ const date = new Date(value);
29
+ if (Number.isNaN(date.valueOf())) return value;
30
+ const weekdays = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
31
+ const months = [
32
+ "January", "February", "March", "April", "May", "June",
33
+ "July", "August", "September", "October", "November", "December",
34
+ ];
35
+ return `${weekdays[date.getUTCDay()]}, ${date.getUTCDate()} ${months[date.getUTCMonth()]} ${date.getUTCFullYear()} `
36
+ + `at ${String(date.getUTCHours()).padStart(2, "0")}:${String(date.getUTCMinutes()).padStart(2, "0")} UTC`;
37
+ }
38
+
39
+ function roleDetails(role: string): { name: string; description: string } {
40
+ const roles: Record<string, { name: string; description: string }> = {
41
+ owner: { name: "Infrastructure owner", description: "Manage the deployment, workspace settings, and team access." },
42
+ administrator: { name: "Administrator", description: "Configure the workspace and manage team access." },
43
+ workflow_author: { name: "Workflow author", description: "Build, test, and publish safety workflows." },
44
+ reviewer: { name: "Analyst", description: "Review reports, contact reporters, and record decisions." },
45
+ appeal_reviewer: { name: "Appeal analyst", description: "Review appeals independently and communicate their outcomes." },
46
+ quality_reviewer: { name: "Quality reviewer", description: "Review decision quality and AI quality samples." },
47
+ operations: { name: "Operations", description: "Monitor and recover delivery and workflow operations." },
48
+ auditor: { name: "Auditor", description: "Review reports, outcomes, and audit history without changing them." },
49
+ };
50
+ return roles[role] ?? {
51
+ name: role.replaceAll("_", " ").replace(/^./u, (character) => character.toUpperCase()),
52
+ description: "Work with the safety team in this workspace.",
53
+ };
54
+ }
55
+
56
+ function relativeLuminance(hex: string): number {
57
+ const channels = [1, 3, 5].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255)
58
+ .map((value) => value <= 0.04045 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4);
59
+ return 0.2126 * channels[0]! + 0.7152 * channels[1]! + 0.0722 * channels[2]!;
60
+ }
61
+
62
+ function contrast(left: string, right: string): number {
63
+ const [lighter, darker] = [relativeLuminance(left), relativeLuminance(right)].sort((a, b) => b - a);
64
+ return (lighter! + 0.05) / (darker! + 0.05);
65
+ }
66
+
67
+ function actionColors(brand: WorkspaceBrand): { background: string; foreground: string } {
68
+ if (contrast(brand.primary_color, brand.primary_contrast_color) >= 4.5) {
69
+ return { background: brand.primary_color, foreground: brand.primary_contrast_color };
70
+ }
71
+ const foreground = contrast(brand.primary_color, "#ffffff") >= contrast(brand.primary_color, "#111111")
72
+ ? "#ffffff" : "#111111";
73
+ return { background: brand.primary_color, foreground };
74
+ }
75
+
76
+ function logoHtml(brand: WorkspaceBrand): string {
77
+ if (brand.logo_url) {
78
+ return `<img src="${escapeHtml(brand.logo_url)}" width="44" alt="${escapeHtml(brand.logo_alt || brand.organization_name)}" `
79
+ + `style="display:block;width:auto;max-width:132px;height:auto;max-height:44px;border:0;outline:none;text-decoration:none" />`;
80
+ }
81
+ return `<span style="display:inline-block;width:42px;height:42px;line-height:42px;border-radius:${Math.min(brand.border_radius, 12)}px;`
82
+ + `background-color:${brand.primary_color};color:${actionColors(brand).foreground};font-size:18px;font-weight:700;text-align:center">`
83
+ + `${escapeHtml(brand.organization_name.slice(0, 1).toUpperCase())}</span>`;
84
+ }
85
+
86
+ function detailsHtml(brand: WorkspaceBrand, details: Detail[]): string {
87
+ if (!details.length) return "";
88
+ return `<table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" `
89
+ + `style="margin:24px 0 0;border:1px solid ${brand.border_color};border-radius:${Math.min(brand.border_radius, 14)}px;background-color:${brand.background_color}">`
90
+ + details.map((detail, index) => `<tr><td class="detail-row detail-label" width="132" style="width:132px;padding:${index ? "12px 18px" : "16px 18px 12px"};`
91
+ + `${index ? `border-top:1px solid ${brand.border_color};` : ""}color:${brand.muted_color};font-size:13px;line-height:1.45;vertical-align:top;white-space:nowrap">`
92
+ + `${escapeHtml(detail.label)}</td><td class="detail-row detail-value" align="left" style="padding:${index ? "12px 18px 12px 0" : "16px 18px 12px 0"};`
93
+ + `${index ? `border-top:1px solid ${brand.border_color};` : ""}color:${brand.text_color};font-size:13px;line-height:1.45;font-weight:700;vertical-align:top">`
94
+ + `${escapeHtml(detail.value)}</td></tr>`).join("")
95
+ + "</table>";
96
+ }
97
+
98
+ function frame(input: {
99
+ brand: WorkspaceBrand;
100
+ subject: string;
101
+ preview: string;
102
+ category: string;
103
+ heading: string;
104
+ intro: string;
105
+ contentHtml: string;
106
+ details?: Detail[];
107
+ action?: { label: string; url: string };
108
+ afterActionHtml?: string;
109
+ footerHtml: string;
110
+ }): string {
111
+ const { brand } = input;
112
+ const radius = Math.min(brand.border_radius, 16);
113
+ const button = actionColors(brand);
114
+ const action = input.action ? `<table role="presentation" cellspacing="0" cellpadding="0" border="0" style="margin:28px 0 0"><tr>`
115
+ + `<td bgcolor="${button.background}" style="border-radius:${Math.min(radius, 10)}px;background-color:${button.background}">`
116
+ + `<a href="${escapeHtml(input.action.url)}" role="button" style="display:inline-block;min-width:148px;padding:14px 22px;border:1px solid ${button.background};`
117
+ + `border-radius:${Math.min(radius, 10)}px;background-color:${button.background};color:${button.foreground};font-size:15px;line-height:20px;font-weight:700;text-align:center;text-decoration:none">`
118
+ + `${escapeHtml(input.action.label)}</a></td></tr></table>` : "";
119
+ return `<!doctype html>
120
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
121
+ <meta name="color-scheme" content="light"><meta name="supported-color-schemes" content="light"><title>${escapeHtml(input.subject)}</title>
122
+ <style>@media only screen and (max-width:620px){.outer-pad{padding:0!important}.email-card{border-radius:0!important;border-left:0!important;border-right:0!important}.email-pad{padding-left:22px!important;padding-right:22px!important}.detail-row{display:block!important;width:auto!important;padding:10px 16px!important;text-align:left!important;white-space:normal!important}.detail-label{padding-bottom:3px!important}.detail-value{padding-top:0!important;border-top:0!important}}</style></head>
123
+ <body bgcolor="${brand.background_color}" style="margin:0;padding:0;background-color:${brand.background_color};color:${brand.text_color};font-family:${escapeHtml(brand.body_font_family)};-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%">
124
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0;color:transparent">${escapeHtml(input.preview)}&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;&#847;&zwnj;&nbsp;</div>
125
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" bgcolor="${brand.background_color}" style="width:100%;background-color:${brand.background_color}"><tr><td class="outer-pad" align="center" style="padding:36px 16px">
126
+ <table role="article" aria-roledescription="email" aria-label="${escapeHtml(input.subject)}" class="email-card" width="100%" cellspacing="0" cellpadding="0" border="0" bgcolor="${brand.surface_color}" style="width:100%;max-width:600px;background-color:${brand.surface_color};border:1px solid ${brand.border_color};border-radius:${radius}px;overflow:hidden">
127
+ <tr><td height="6" bgcolor="${brand.primary_color}" style="height:6px;background-color:${brand.primary_color};font-size:0;line-height:0">&nbsp;</td></tr>
128
+ <tr><td class="email-pad" style="padding:28px 34px 18px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0"><tr><td width="56" valign="middle" style="width:56px">${logoHtml(brand)}</td><td valign="middle" style="padding-left:12px"><div style="color:${brand.text_color};font-size:16px;line-height:1.25;font-weight:700">${escapeHtml(brand.organization_name)}</div><div style="margin-top:3px;color:${brand.muted_color};font-size:12px;line-height:1.3;font-weight:700;letter-spacing:.06em;text-transform:uppercase">${escapeHtml(input.category)}</div></td></tr></table></td></tr>
129
+ <tr><td class="email-pad" style="padding:14px 34px 32px"><h1 style="margin:0;color:${brand.text_color};font-family:${escapeHtml(brand.heading_font_family)};font-size:30px;line-height:1.18;font-weight:700;letter-spacing:-.01em">${escapeHtml(input.heading)}</h1>
130
+ <p style="margin:16px 0 0;color:${brand.text_color};font-size:16px;line-height:1.65">${escapeHtml(input.intro)}</p>
131
+ ${input.contentHtml}${detailsHtml(brand, input.details ?? [])}${action}${input.afterActionHtml ?? ""}</td></tr>
132
+ <tr><td class="email-pad" style="padding:22px 34px;border-top:1px solid ${brand.border_color};color:${brand.muted_color};font-size:12px;line-height:1.65">${input.footerHtml}</td></tr>
133
+ </table></td></tr></table></body></html>`;
134
+ }
135
+
136
+ function caseKind(templateVersionId: string | null): "receipt" | "followup" | "outcome" | "appeal_outcome" {
9
137
  if (templateVersionId?.includes("received")) return "receipt";
138
+ if (templateVersionId?.startsWith("appeal-outcome")) return "appeal_outcome";
10
139
  if (templateVersionId?.includes("outcome")) return "outcome";
11
140
  return "followup";
12
141
  }
13
142
 
143
+ function subjectIdentity(prefix: string, brand: WorkspaceBrand): string {
144
+ const value = prefix.trim();
145
+ return !value || ["safest report update", "report update"].includes(value.toLowerCase())
146
+ ? brand.organization_name : value;
147
+ }
148
+
14
149
  export function renderReporterEmail(input: {
15
150
  brand: WorkspaceBrand;
16
151
  body: string;
17
152
  reportReference: string;
18
153
  templateVersionId: string | null;
19
154
  subjectPrefix: string;
20
- }): { subject: string; text: string; html: string } {
21
- const messageKind = kind(input.templateVersionId);
22
- const heading = messageKind === "receipt"
23
- ? "We received your report"
24
- : messageKind === "outcome" ? "An outcome is ready" : "A message from the safety team";
25
- const preview = messageKind === "receipt"
26
- ? `Your report ${input.reportReference} is now with the safety team.`
27
- : `There is an update for report ${input.reportReference}.`;
28
- const subject = messageKind === "receipt"
29
- ? `${input.brand.organization_name} received your report — ${input.reportReference}`
30
- : `${input.subjectPrefix} ${input.reportReference}`;
31
- const messageHtml = escapeHtml(input.body).replaceAll("\n", "<br />");
32
- const logo = input.brand.logo_url
33
- ? `<img src="${escapeHtml(input.brand.logo_url)}" width="40" alt="${escapeHtml(input.brand.logo_alt || input.brand.organization_name)}" style="display:block;max-width:120px;max-height:40px;border:0" />`
34
- : `<span style="display:inline-block;width:36px;height:36px;line-height:36px;border-radius:10px;background:${input.brand.primary_color};color:${input.brand.primary_contrast_color};font-size:18px;font-weight:700;text-align:center">${escapeHtml(input.brand.organization_name.slice(0, 1).toUpperCase())}</span>`;
35
- const html = `<!doctype html>
36
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(subject)}</title></head>
37
- <body style="margin:0;padding:0;background:${input.brand.background_color};color:${input.brand.text_color};font-family:Arial,Helvetica,sans-serif">
38
- <div style="display:none;max-height:0;overflow:hidden;opacity:0">${escapeHtml(preview)}</div>
39
- <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:${input.brand.background_color}"><tr><td align="center" style="padding:32px 16px">
40
- <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background:${input.brand.surface_color};border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 16)}px;overflow:hidden">
41
- <tr><td style="height:6px;background:${input.brand.primary_color};font-size:0">&nbsp;</td></tr>
42
- <tr><td style="padding:28px 32px 12px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td>${logo}</td><td align="right" style="color:${input.brand.muted_color};font-size:13px;font-weight:700">${escapeHtml(input.brand.organization_name)}</td></tr></table></td></tr>
43
- <tr><td style="padding:12px 32px 8px"><h1 style="margin:0;font-size:26px;line-height:1.25;color:${input.brand.text_color};font-family:Georgia,'Times New Roman',serif">${escapeHtml(heading)}</h1></td></tr>
44
- <tr><td style="padding:12px 32px"><div style="padding:20px;border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 14)}px;background:${input.brand.background_color};font-size:16px;line-height:1.6;color:${input.brand.text_color}">${messageHtml}</div></td></tr>
45
- <tr><td style="padding:10px 32px 24px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="color:${input.brand.muted_color};font-size:13px">Report reference</td><td align="right" style="color:${input.brand.text_color};font-size:13px;font-weight:700">${escapeHtml(input.reportReference)}</td></tr></table></td></tr>
46
- <tr><td style="padding:20px 32px;border-top:1px solid ${input.brand.border_color};color:${input.brand.muted_color};font-size:12px;line-height:1.55">Reply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case. This message contains only the information needed for your report update.</td></tr>
47
- </table></td></tr></table></body></html>`;
155
+ audience?: CaseEmailAudience;
156
+ }): RenderedEmail {
157
+ const emailKind = caseKind(input.templateVersionId);
158
+ const audience = input.audience ?? "reporter";
159
+ const identity = subjectIdentity(input.subjectPrefix, input.brand);
160
+ const copy = emailKind === "receipt" ? {
161
+ category: "Report received",
162
+ heading: "We received your report",
163
+ intro: "Thank you for taking the time to report this. Our safety team has received the information you shared.",
164
+ subject: `${input.brand.organization_name} received your report — ${input.reportReference}`,
165
+ preview: `Your report ${input.reportReference} is now with the safety team.`,
166
+ status: "Received",
167
+ } : emailKind === "appeal_outcome" ? {
168
+ category: "Appeal outcome",
169
+ heading: "Your appeal has been reviewed",
170
+ intro: "The safety team has completed its review of the appeal and shared the decision below.",
171
+ subject: `${identity}: appeal outcome — ${input.reportReference}`,
172
+ preview: `An appeal outcome is available for ${input.reportReference}.`,
173
+ status: "Appeal reviewed",
174
+ } : emailKind === "outcome" ? {
175
+ category: "Report outcome",
176
+ heading: audience === "affected_user" ? "There’s an update on a report involving you" : "There’s an update on your report",
177
+ intro: "The safety team has completed a review and shared an update below.",
178
+ subject: `${identity}: report outcome — ${input.reportReference}`,
179
+ preview: `A report outcome is available for ${input.reportReference}.`,
180
+ status: "Reviewed",
181
+ } : {
182
+ category: "Safety team message",
183
+ heading: "A message from our safety team",
184
+ intro: audience === "affected_user"
185
+ ? "The safety team has sent you a message about a report involving you."
186
+ : "The safety team has sent you a message about your report.",
187
+ subject: `${identity}: safety team message — ${input.reportReference}`,
188
+ preview: `The safety team sent a message about ${input.reportReference}.`,
189
+ status: "Update available",
190
+ };
191
+ const replyCopy = "Reply directly to this email if you have a question or more information to share. Your reply will be added to the case for the safety team to review. Keep the reply address unchanged.";
192
+ const html = frame({
193
+ brand: input.brand,
194
+ subject: copy.subject,
195
+ preview: copy.preview,
196
+ category: copy.category,
197
+ heading: copy.heading,
198
+ intro: copy.intro,
199
+ contentHtml: `<div style="margin:24px 0 0;padding:20px;border-left:4px solid ${input.brand.accent_color};border-radius:${Math.min(input.brand.border_radius, 12)}px;background-color:${input.brand.background_color};color:${input.brand.text_color};font-size:16px;line-height:1.65">${messageHtml(input.body)}</div>`,
200
+ details: [
201
+ { label: "Report reference", value: input.reportReference },
202
+ { label: "Status", value: copy.status },
203
+ ],
204
+ footerHtml: `${escapeHtml(replyCopy)}<br><span style="display:inline-block;margin-top:8px">For privacy, this email includes only the information needed for this update.</span>`,
205
+ });
48
206
  return {
49
- subject,
207
+ subject: copy.subject,
50
208
  html,
51
- text: `${heading}\n\n${input.body}\n\nReport reference: ${input.reportReference}\n\nReply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case.`,
209
+ text: `${copy.heading}\n\n${copy.intro}\n\n${input.body}\n\nReport reference: ${input.reportReference}\nStatus: ${copy.status}\n\n${replyCopy}\n\nFor privacy, this email includes only the information needed for this update.`,
210
+ };
211
+ }
212
+
213
+ export function renderAuthenticationEmail(input: {
214
+ brand: WorkspaceBrand;
215
+ kind: AuthenticationEmailKind;
216
+ url: string;
217
+ role?: string;
218
+ expiresAt?: string;
219
+ }): RenderedEmail {
220
+ const host = channelHost(input.url);
221
+ const role = roleDetails(input.role ?? "reviewer");
222
+ const expiration = input.expiresAt ? readableUtc(input.expiresAt) : "";
223
+ const copy = input.kind === "invitation" ? {
224
+ subject: `You’re invited to join ${input.brand.organization_name}`,
225
+ preview: `Accept your invitation to join ${input.brand.organization_name} as ${role.name}.`,
226
+ category: "Team invitation",
227
+ heading: `Join ${input.brand.organization_name}`,
228
+ intro: `You’ve been invited to join the ${input.brand.organization_name} safety workspace as ${role.name}.`,
229
+ details: [
230
+ { label: "Role", value: role.name },
231
+ { label: "Access", value: role.description },
232
+ ...(expiration ? [{ label: "Invitation expires", value: expiration }] : []),
233
+ ],
234
+ action: "Accept invitation",
235
+ security: "This secure invitation can be used once. If you were not expecting it, you can safely ignore this email.",
236
+ } : input.kind === "password_reset" ? {
237
+ subject: `Reset your ${input.brand.organization_name} password`,
238
+ preview: `Use this secure link to reset your ${input.brand.organization_name} password.`,
239
+ category: "Account security",
240
+ heading: "Reset your password",
241
+ intro: `We received a request to reset the password for your ${input.brand.organization_name} safety workspace account.`,
242
+ details: [{ label: "Link expires", value: "30 minutes" }],
243
+ action: "Reset password",
244
+ security: "This secure link can be used once. Resetting your password signs out your other sessions. If you did not request a password reset, ignore this email; your password will not change.",
245
+ } : {
246
+ subject: `Verify your email for ${input.brand.organization_name}`,
247
+ preview: `Confirm your email address to finish setting up your ${input.brand.organization_name} account.`,
248
+ category: "Account verification",
249
+ heading: "Verify your email address",
250
+ intro: `Confirm that this email address belongs to you to finish setting up your ${input.brand.organization_name} safety workspace account.`,
251
+ details: [{ label: "Link expires", value: "24 hours" }],
252
+ action: "Verify email address",
253
+ security: "This secure link can be used once. If you did not create this account, you can safely ignore this email.",
254
+ };
255
+ const afterAction = `<p style="margin:20px 0 0;color:${input.brand.muted_color};font-size:12px;line-height:1.6">`
256
+ + `If the button does not work, copy and paste this address into your browser:<br>`
257
+ + `<a href="${escapeHtml(input.url)}" style="color:${input.brand.primary_color};text-decoration:underline;word-break:break-all">${escapeHtml(input.url)}</a></p>`;
258
+ const footer = `${escapeHtml(copy.security)}<br><span style="display:inline-block;margin-top:8px">`
259
+ + `This account email was sent by ${escapeHtml(input.brand.organization_name)}. The secure link opens ${escapeHtml(host)}.</span>`
260
+ + `<br><span style="display:inline-block;margin-top:8px">This automated account email cannot accept replies. If you need help, contact your workspace administrator through your usual support channel.</span>`;
261
+ const detailText = copy.details.map((detail) => `${detail.label}: ${detail.value}`).join("\n");
262
+ return {
263
+ subject: copy.subject,
264
+ html: frame({
265
+ brand: input.brand,
266
+ subject: copy.subject,
267
+ preview: copy.preview,
268
+ category: copy.category,
269
+ heading: copy.heading,
270
+ intro: copy.intro,
271
+ contentHtml: "",
272
+ details: copy.details,
273
+ action: { label: copy.action, url: input.url },
274
+ afterActionHtml: afterAction,
275
+ footerHtml: footer,
276
+ }),
277
+ text: `${copy.heading}\n\n${copy.intro}\n\n${detailText}\n\n${copy.action}: ${input.url}\n\n${copy.security}\n\nThis account email was sent by ${input.brand.organization_name}. The secure link opens ${host}.\n\nThis automated account email cannot accept replies. If you need help, contact your workspace administrator through your usual support channel.`,
52
278
  };
53
279
  }
@@ -5,6 +5,8 @@ export type AuthenticationEmailBinding = {
5
5
  subject: string;
6
6
  text: string;
7
7
  html: string;
8
+ replyTo?: string | { email: string; name?: string };
9
+ headers?: Record<string, string>;
8
10
  }): Promise<unknown>;
9
11
  };
10
12
 
@@ -55,14 +57,17 @@ export function reporterEmailFrom(env: unknown): string {
55
57
  export function reporterEmailSubjectPrefix(env: unknown): string {
56
58
  const bindings = emailEnvironment(env);
57
59
  return (bindings.REPORTER_EMAIL_SUBJECT_PREFIX ?? bindings.EMAIL_SUBJECT_PREFIX ?? "").trim().slice(0, 120)
58
- || "Safest report update";
60
+ || "Report update";
59
61
  }
60
62
 
61
63
  export function reporterEmailConfigured(env: unknown): boolean {
62
64
  return Boolean(reporterEmailBinding(env) && reporterEmailFrom(env));
63
65
  }
64
66
 
65
- const replyTokenPattern = /^r1\.([a-z0-9_-]{16})\.([0-9a-f]{8})\.([0-9a-f]{24})$/u;
67
+ export type CaseReplyAudience = "reporter" | "affected_user";
68
+
69
+ const legacyReplyTokenPattern = /^r1\.([a-z0-9_-]{16})\.([0-9a-f]{8})\.([0-9a-f]{24})$/u;
70
+ const replyTokenPattern = /^r2([ra])\.([a-z0-9_-]{16})\.([0-9a-f]{8})\.([0-9a-f]{24})$/u;
66
71
  const encoder = new TextEncoder();
67
72
 
68
73
  function hex(bytes: Uint8Array): string {
@@ -86,12 +91,14 @@ export async function reporterReplyToken(input: {
86
91
  publicReference: string;
87
92
  expiresAt: number;
88
93
  signingSecret: string;
94
+ audience?: CaseReplyAudience;
89
95
  }): Promise<string> {
90
96
  const reference = input.publicReference.replace(/^RPT-/u, "").toLowerCase();
91
97
  if (!/^[a-z0-9_-]{16}$/u.test(reference)) throw new Error("The report reference cannot be used for an email reply token.");
92
98
  const expiry = input.expiresAt.toString(16).padStart(8, "0");
93
99
  if (!/^[0-9a-f]{8}$/u.test(expiry)) throw new Error("The reporter reply expiry is outside the supported range.");
94
- const payload = `r1.${reference}.${expiry}`;
100
+ const audience = input.audience === "affected_user" ? "a" : "r";
101
+ const payload = `r2${audience}.${reference}.${expiry}`;
95
102
  return `${payload}.${hex(await replySignature(input.signingSecret, payload))}`;
96
103
  }
97
104
 
@@ -99,16 +106,24 @@ export async function verifyReporterReplyToken(
99
106
  token: string,
100
107
  signingSecret: string,
101
108
  currentTime = Math.floor(Date.now() / 1_000),
102
- ): Promise<{ publicReference: string; expiresAt: number } | null> {
103
- const match = replyTokenPattern.exec(token);
104
- if (!match) return null;
105
- const expiry = Number.parseInt(match[2]!, 16);
106
- const supplied = Uint8Array.from(match[3]!.match(/.{2}/gu) ?? [], (pair) => Number.parseInt(pair, 16));
109
+ ): Promise<{ publicReference: string; expiresAt: number; audience: CaseReplyAudience } | null> {
110
+ const current = replyTokenPattern.exec(token);
111
+ const legacy = current ? null : legacyReplyTokenPattern.exec(token);
112
+ if (!current && !legacy) return null;
113
+ const reference = current?.[2] ?? legacy?.[1] ?? "";
114
+ const expiryHex = current?.[3] ?? legacy?.[2] ?? "";
115
+ const signatureHex = current?.[4] ?? legacy?.[3] ?? "";
116
+ const expiry = Number.parseInt(expiryHex, 16);
117
+ const supplied = Uint8Array.from(signatureHex.match(/.{2}/gu) ?? [], (pair) => Number.parseInt(pair, 16));
107
118
  if (!expiry || expiry <= currentTime) return null;
108
- const payload = `r1.${match[1]}.${match[2]}`;
119
+ const payload = current ? `r2${current[1]}.${reference}.${expiryHex}` : `r1.${reference}.${expiryHex}`;
109
120
  const expected = await replySignature(signingSecret, payload);
110
121
  if (!constantTimeEqual(supplied, expected)) return null;
111
- return { publicReference: `RPT-${match[1]!.toUpperCase()}`, expiresAt: expiry };
122
+ return {
123
+ publicReference: `RPT-${reference.toUpperCase()}`,
124
+ expiresAt: expiry,
125
+ audience: current?.[1] === "a" ? "affected_user" : "reporter",
126
+ };
112
127
  }
113
128
 
114
129
  export async function reporterReplyAddress(input: {
@@ -116,6 +131,7 @@ export async function reporterReplyAddress(input: {
116
131
  publicReference: string;
117
132
  signingSecret: string;
118
133
  expiresAt: number;
134
+ audience?: CaseReplyAudience;
119
135
  }): Promise<string> {
120
136
  const separator = input.fromAddress.lastIndexOf("@");
121
137
  if (separator < 1) throw new Error("The reporter email address is invalid.");
@@ -281,7 +281,8 @@ export async function createReport(
281
281
  const receiptMessageId = crypto.randomUUID();
282
282
  const receiptNoticeId = crypto.randomUUID();
283
283
  const receiptDeliveryId = crypto.randomUUID();
284
- const receiptBody = `We received your report ${publicReference}. It is now with the safety team. We will email you when there is an update, and you can reply directly to any report email.`;
284
+ const receiptBody = "Thank you for speaking up. Our safety team will review the information you shared. "
285
+ + "We’ll email you here if we need more information or when there is an update.";
285
286
  const directEmail = Boolean(submission.reporter.email && options.emailEnabled);
286
287
  const webhookNotification = Boolean(!directEmail && options.notificationEnabled
287
288
  && (submission.reporter.email || submission.reporter.reference));