create-safest-tools 0.4.2 → 0.5.1

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.
@@ -11,7 +11,7 @@
11
11
  <link rel="manifest" href="./manifest.webmanifest" />
12
12
  <link rel="stylesheet" href="./styles.css" />
13
13
  </head>
14
- <body class="login-mode">
14
+ <body class="auth-resolving">
15
15
  <svg class="svg-defs" aria-hidden="true">
16
16
  <defs>
17
17
  <symbol id="i-home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 21v-8h6v8"/></symbol>
@@ -43,7 +43,15 @@
43
43
  </div>
44
44
 
45
45
  <main id="main">
46
- <section id="login" class="login-layout" aria-labelledby="login-title">
46
+ <section id="session-loading" class="session-loading" aria-live="polite" aria-busy="true">
47
+ <div class="session-loading-card">
48
+ <span class="session-loading-mark" aria-hidden="true"></span>
49
+ <strong>Opening your workspace</strong>
50
+ <span>Restoring your secure session…</span>
51
+ </div>
52
+ </section>
53
+
54
+ <section id="login" class="login-layout" aria-labelledby="login-title" hidden>
47
55
  <div class="login-story">
48
56
  <p class="eyebrow light">Safest Resolve</p>
49
57
  <h1>Every report gets<br />a real next step.</h1>
@@ -1107,6 +1107,81 @@ body.report-open .content { margin-right: 0; }
1107
1107
  /* Authoritative responsive contract for the console shell and dense editors. */
1108
1108
  body.login-mode .topbar { display: flex; justify-content: flex-start; }
1109
1109
  body.login-mode .top-actions { margin-left: auto; }
1110
+ .session-loading {
1111
+ min-height: calc(100vh - 72px);
1112
+ display: grid;
1113
+ place-items: center;
1114
+ padding: 32px;
1115
+ background: var(--background, #f4f7f3);
1116
+ }
1117
+ .session-loading[hidden] { display: none; }
1118
+ .session-loading-card {
1119
+ display: grid;
1120
+ justify-items: center;
1121
+ gap: 8px;
1122
+ color: var(--text, #183228);
1123
+ text-align: center;
1124
+ }
1125
+ .session-loading-card strong { font-size: 16px; }
1126
+ .session-loading-card > span:last-child { color: var(--muted, #66766f); font-size: 13px; }
1127
+ .session-loading-mark {
1128
+ width: 30px;
1129
+ height: 30px;
1130
+ margin-bottom: 4px;
1131
+ border: 3px solid color-mix(in srgb, var(--primary, #165b43) 18%, transparent);
1132
+ border-top-color: var(--primary, #165b43);
1133
+ border-radius: 50%;
1134
+ animation: session-loading-spin .8s linear infinite;
1135
+ }
1136
+ @keyframes session-loading-spin { to { transform: rotate(360deg); } }
1137
+ @media (prefers-reduced-motion: reduce) { .session-loading-mark { animation: none; } }
1138
+
1139
+ /* Report drawer: readable case record with technical execution kept secondary. */
1140
+ .report-drawer .report-detail-content {
1141
+ display: block;
1142
+ max-width: 1180px;
1143
+ margin-inline: auto;
1144
+ padding: 22px 24px 36px;
1145
+ }
1146
+ .report-drawer .report-detail-content > section,
1147
+ .report-drawer .detail-grid section,
1148
+ .report-drawer .detail-actions form {
1149
+ border-color: #dce5de;
1150
+ border-radius: 12px;
1151
+ background: #fff;
1152
+ }
1153
+ .report-drawer .report-detail-content > section { margin-top: 16px; padding: 20px; }
1154
+ .report-drawer .detail-grid { grid-template-columns: 1fr 1fr; gap: 16px; }
1155
+ .report-drawer .claim-bar,
1156
+ .report-drawer .detail-actions,
1157
+ .report-drawer .surface-error { width: 100%; }
1158
+ .report-drawer .detail-actions { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: start; gap: 16px; }
1159
+ .case-activity-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
1160
+ .case-activity-heading h3 { margin: 0 !important; font-size: 20px; }
1161
+ .case-activity-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
1162
+ .case-activity-heading > span { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #315b47; background: #edf5ef; font-size: 11px; font-weight: 750; }
1163
+ .case-activity-section .report-timeline { padding-left: 2px; }
1164
+ .case-activity-section .timeline-event { grid-template-columns: 36px minmax(0, 1fr); gap: 12px; min-height: 76px; }
1165
+ .case-activity-section .timeline-event:not(:last-child)::before { top: 32px; bottom: 0; left: 15px; width: 2px; background: #dfe8e1; }
1166
+ .case-activity-section .timeline-marker { width: 32px; height: 32px; border-width: 2px; font-size: 13px; }
1167
+ .case-activity-section .timeline-event > div { padding: 2px 0 18px; }
1168
+ .case-activity-section .timeline-event strong { color: #172d23; font-size: 13px; line-height: 1.35; }
1169
+ .case-activity-section .timeline-event span { margin-top: 4px; color: #40584c; font-size: 12px; line-height: 1.5; }
1170
+ .case-activity-section .timeline-event small { margin-top: 6px; color: #75827b; font-size: 10px; line-height: 1.4; }
1171
+ .workflow-technical { margin-top: 4px; border-top: 1px solid #e4eae5; }
1172
+ .workflow-technical summary { padding: 15px 2px 5px; color: #40564b; cursor: pointer; font-size: 12px; font-weight: 750; }
1173
+ .workflow-technical summary span { margin-left: 6px; color: var(--muted); font-weight: 550; }
1174
+ .workflow-technical .report-run-list { margin: 10px 0 0; }
1175
+ .technical-event-count { margin: 10px 2px 2px; color: var(--muted); font-size: 10px; }
1176
+ .report-drawer .case-message { max-width: min(82%, 720px); padding: 13px 15px; font-size: 12px; line-height: 1.55; }
1177
+ .report-drawer .case-message small { font-size: 10px; line-height: 1.45; }
1178
+
1179
+ @media (max-width: 800px) {
1180
+ .report-drawer .report-detail-content { padding: 14px 14px 28px; }
1181
+ .report-drawer .detail-grid,
1182
+ .report-drawer .detail-actions { grid-template-columns: 1fr; }
1183
+ .report-drawer .submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
1184
+ }
1110
1185
 
1111
1186
  @media (max-width: 760px) {
1112
1187
  body { min-width: 0; overflow-x: clip; }
@@ -48,7 +48,7 @@
48
48
  "required": ["enabled", "fromAddress"],
49
49
  "properties": {
50
50
  "enabled": { "type": "boolean" },
51
- "fromAddress": { "type": "string", "maxLength": 254 },
51
+ "fromAddress": { "type": "string", "maxLength": 254, "description": "Receivable reporter mailbox. Resolve routes signed plus-addressed replies for this exact address to the Worker." },
52
52
  "authFromAddress": { "type": "string", "maxLength": 254 }
53
53
  }
54
54
  },
@@ -66,6 +66,55 @@ async function fetchWorkersAccountSettings(accountId, token, fetchImpl) {
66
66
  return { ok: true, settings: body.result };
67
67
  }
68
68
 
69
+ async function cloudflareApi(path, token, fetchImpl, init = {}) {
70
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
71
+ ...init,
72
+ headers: {
73
+ authorization: `Bearer ${token}`,
74
+ accept: "application/json",
75
+ ...(init.body ? { "content-type": "application/json" } : {}),
76
+ ...init.headers,
77
+ },
78
+ });
79
+ const body = await response.json().catch(() => null);
80
+ if (!response.ok || body?.success !== true) {
81
+ const detail = Array.isArray(body?.errors) && body.errors[0]?.message ? `: ${body.errors[0].message}` : "";
82
+ throw new Error(`Cloudflare API ${response.status}${detail}`);
83
+ }
84
+ return body.result;
85
+ }
86
+
87
+ async function emailZone(domain, token, fetchImpl) {
88
+ const labels = domain.toLowerCase().split(".").filter(Boolean);
89
+ for (let index = 0; index <= Math.max(0, labels.length - 2); index += 1) {
90
+ const candidate = labels.slice(index).join(".");
91
+ const zones = await cloudflareApi(`/zones?name=${encodeURIComponent(candidate)}&status=active&per_page=5`, token, fetchImpl);
92
+ if (Array.isArray(zones) && zones.length === 1 && typeof zones[0]?.id === "string") return zones[0];
93
+ }
94
+ throw new Error(`Cloudflare could not find an active zone that owns the reporter email domain ${domain}.`);
95
+ }
96
+
97
+ export async function ensureReporterReplyRouting({ reporterAddress, wranglerToken, fetchImpl = fetch }) {
98
+ const separator = reporterAddress.lastIndexOf("@");
99
+ if (separator < 1) throw new Error("The reporter email address is invalid.");
100
+ const domain = reporterAddress.slice(separator + 1).toLowerCase();
101
+ const zone = await emailZone(domain, wranglerToken, fetchImpl);
102
+ const settings = await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl);
103
+ if (settings?.enabled !== true || settings?.status !== "ready") {
104
+ throw new Error(
105
+ `Cloudflare Email Routing is not ready for ${zone.name}. Enable it before setup so ${reporterAddress} can receive case replies; ` +
106
+ "Resolve will not change a domain's MX records automatically.",
107
+ );
108
+ }
109
+ if (settings.support_subaddress !== true) {
110
+ await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl, {
111
+ method: "PATCH",
112
+ body: JSON.stringify({ support_subaddress: true }),
113
+ });
114
+ }
115
+ return { zoneId: zone.id, zoneName: zone.name, address: reporterAddress, subaddressingEnabled: true };
116
+ }
117
+
69
118
  export function isWorkersPaidUsageModel(value) {
70
119
  return String(value ?? "").trim().toLowerCase() === "standard";
71
120
  }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { configureAuthentication, requiredOAuthSecrets } from "./reports-auth-onboarding.mjs";
7
7
  import {
8
8
  chooseCloudflareAccount,
9
+ ensureReporterReplyRouting,
9
10
  ensureWranglerAuthentication,
10
11
  saveCloudflareAccountSelection,
11
12
  verifyWorkersPaid,
@@ -36,36 +37,83 @@ function parseArguments(argv) {
36
37
  return result;
37
38
  }
38
39
 
39
- function runWrangler(args, label) {
40
+ function runWrangler(args, label, { stdin = "inherit" } = {}) {
40
41
  return new Promise((resolvePromise, reject) => {
41
42
  console.log(`\n${label}`);
42
- 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
+ });
43
48
  child.once("error", reject);
44
49
  child.once("exit", (code, signal) => code === 0 ? resolvePromise() : reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`)));
45
50
  });
46
51
  }
47
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
+
48
74
  export function isExistingResourceError(output) {
49
75
  return /already exists|already (?:been )?taken|duplicate resource/iu.test(output);
50
76
  }
51
77
 
52
- function ensureWranglerResource(args, label) {
53
- return new Promise((resolvePromise, reject) => {
54
- console.log(`\n${label}`);
55
- const child = spawn(process.execPath, [wrangler, ...args], { cwd: projectRoot, env: process.env, stdio: ["inherit", "pipe", "pipe"] });
56
- let output = "";
57
- child.stdout.on("data", (chunk) => { output += chunk; process.stdout.write(chunk); });
58
- child.stderr.on("data", (chunk) => { output += chunk; process.stderr.write(chunk); });
59
- child.once("error", reject);
60
- child.once("exit", (code, signal) => {
61
- if (code === 0) return resolvePromise();
62
- if (isExistingResourceError(output)) {
63
- console.log("Resource already exists; reusing the exact configured name.");
64
- return resolvePromise();
65
- }
66
- reject(new Error(`${label} failed${signal ? ` with signal ${signal}` : ` with exit code ${code}`}`));
67
- });
68
- });
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
+ }
69
117
  }
70
118
 
71
119
  async function validateSecrets(path, config) {
@@ -87,6 +135,23 @@ async function hasDatabaseId() {
87
135
  return typeof config?.d1_databases?.[0]?.database_id === "string" && config.d1_databases[0].database_id.length > 0;
88
136
  }
89
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
+
90
155
  async function confirmation(installationId, yes) {
91
156
  if (yes) return;
92
157
  if (!process.stdin.isTTY) throw new Error("interactive confirmation is required; use --yes only after reviewing setup:plan");
@@ -104,6 +169,20 @@ export async function deployReports(argv = process.argv.slice(2)) {
104
169
  console.log(JSON.stringify({ action: options.upgrade ? "upgrade" : "install", ...plan }, null, 2));
105
170
  if (options.planOnly) return { status: "planned", plan };
106
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
+ }
107
186
  const interactive = process.stdin.isTTY && !options.yes;
108
187
  const authentication = await ensureWranglerAuthentication({ wrangler, projectRoot, interactive });
109
188
  const accountSelection = await chooseCloudflareAccount({
@@ -126,7 +205,7 @@ export async function deployReports(argv = process.argv.slice(2)) {
126
205
  await initializeSecrets(secretsPath);
127
206
  console.log(`Created ${secretsPath} with generated owner-only secrets.`);
128
207
  }
129
- if (!options.upgrade) {
208
+ if (!options.upgrade && setupState === "fresh") {
130
209
  const configured = await configureAuthentication({
131
210
  configPath: resolve(projectRoot, options.config),
132
211
  secretsPath,
@@ -138,10 +217,12 @@ export async function deployReports(argv = process.argv.slice(2)) {
138
217
  }
139
218
  await validateSecrets(secretsPath, config);
140
219
  await confirmation(config.installationId, options.yes);
141
- const databaseConfigured = await hasDatabaseId();
142
- if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
143
- if (!options.upgrade && databaseConfigured) throw new Error("this project already has a D1 database_id; use npm run upgrade instead of setup");
144
- if (!options.upgrade) {
220
+ const reporterRouting = await ensureReporterReplyRouting({
221
+ reporterAddress: config.email.fromAddress,
222
+ wranglerToken: authentication.token,
223
+ });
224
+ console.log(`\nReporter replies will route through ${reporterRouting.address}; plus-addressing is enabled and no catch-all is created.`);
225
+ if (!options.upgrade && setupState === "fresh") {
145
226
  await runWrangler(["d1", "create", config.resources.databaseName, "--binding", "DB", "--update-config"], "Create the customer-owned D1 database");
146
227
  }
147
228
  await ensureWranglerResource(["r2", "bucket", "create", config.resources.profileMediaBucketName], "Ensure the private profile-media R2 bucket exists");
@@ -151,9 +232,13 @@ export async function deployReports(argv = process.argv.slice(2)) {
151
232
  await ensureWranglerResource(["queues", "create", config.resources.reportQueueName], "Ensure the report jobs Queue exists");
152
233
  await ensureWranglerResource(["queues", "create", config.resources.deliveryQueueName], "Ensure the delivery jobs Queue exists");
153
234
  await ensureWranglerResource(["queues", "create", config.resources.operationsDlqName], "Ensure the operations dead-letter Queue exists");
154
- await runWrangler(["d1", "migrations", "apply", "DB", "--remote"], "Apply forward-only report migrations");
155
- await runWrangler(["deploy", "--secrets-file", secretsPath, "--strict"], "Deploy the reports Worker and bindings");
156
- 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 });
157
242
  await mkdir(dirname(installationPath), { recursive: true, mode: 0o700 });
158
243
  await writeFile(installationPath, `${JSON.stringify({
159
244
  schemaVersion: 2,
@@ -162,9 +247,9 @@ export async function deployReports(argv = process.argv.slice(2)) {
162
247
  resources: config.resources,
163
248
  ownerEmail: config.owner.email,
164
249
  }, null, 2)}\n`, { mode: 0o600 });
165
- if (!options.upgrade) printOwnerSetup(await issueOwnerSetup({ config }));
250
+ if (ownerSetup) printOwnerSetup(ownerSetup);
166
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.");
167
- return { status: "deployed", plan };
252
+ return { status: "deployed", plan, resumed: !options.upgrade && setupState === "resume" };
168
253
  }
169
254
 
170
255
  if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
@@ -85,6 +85,13 @@ export function parseReportsConfig(value) {
85
85
  const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
86
86
  const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
87
87
  if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
88
+ const emailLocal = emailFromAddress.slice(0, emailFromAddress.lastIndexOf("@"));
89
+ if (email.enabled && /^no-?reply$/u.test(emailLocal)) {
90
+ throw new ReportsConfigError("email.fromAddress must be a receivable mailbox, not a no-reply address");
91
+ }
92
+ if (email.enabled && (emailLocal.includes("+") || emailLocal.length > 10)) {
93
+ throw new ReportsConfigError("email.fromAddress must use a base mailbox of 10 characters or fewer without plus-addressing so replies can be routed securely");
94
+ }
88
95
  if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
89
96
  if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
90
97
  if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
@@ -156,6 +163,11 @@ export function buildReportsPlan(config) {
156
163
  reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
157
164
  authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
158
165
  },
166
+ inboundReporterEmail: {
167
+ address: config.email.fromAddress,
168
+ routingRule: `Exact ${config.email.fromAddress} address routed to this Worker (no catch-all)`,
169
+ subaddressing: "Enabled so signed per-case reply addresses are accepted",
170
+ },
159
171
  staticAssets: "public/",
160
172
  publicHostname,
161
173
  customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
@@ -184,7 +196,7 @@ export function buildReportsPlan(config) {
184
196
  },
185
197
  order: [
186
198
  "Review this plan and the initial infrastructure-owner email.",
187
- "Onboard the sender domain to Cloudflare Email Service before deployment.",
199
+ "Onboard the sender domain to Cloudflare Email Service and enable Email Routing for the reporter address before deployment.",
188
200
  "Initialize local secrets without committing them.",
189
201
  "Create D1, four private R2 buckets, and the three Queues in the customer account.",
190
202
  "Apply the D1 schema baseline and any later forward-only migrations before accepting traffic.",
@@ -56,6 +56,7 @@ import { sha256, signContextToken, verifyContextToken } from "./report-crypto";
56
56
  import { createStoredReportContext, loadStoredReportContext, pruneReportContexts } from "./report-context";
57
57
  import { parseContextSchema, routingContextFacts, validateContextFacts } from "./report-context-schema";
58
58
  import { pendingDeliveryJobs, processDeliveryJob, RetryableDeliveryError } from "./report-delivery";
59
+ import { receiveReporterEmail } from "./report-email-inbound";
59
60
  import { ApiError, allowedOrigin, apiErrorResponse, json, methodNotAllowed, readJsonObject, withCors } from "./report-http";
60
61
  import {
61
62
  cancelHumanTask,
@@ -613,6 +614,7 @@ async function handleWidgetReport(request: Request, env: Env, ctx: ExecutionCont
613
614
  });
614
615
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
615
616
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
617
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
616
618
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
617
619
  }
618
620
 
@@ -649,6 +651,7 @@ async function handleAnonymousWidgetReport(request: Request, env: Env, ctx: Exec
649
651
  });
650
652
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
651
653
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
654
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
652
655
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
653
656
  }
654
657
 
@@ -670,6 +673,7 @@ async function handleServerReport(request: Request, env: Env, ctx: ExecutionCont
670
673
  });
671
674
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
672
675
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
676
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
673
677
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
674
678
  }
675
679
 
@@ -743,6 +747,7 @@ async function handlePublicReport(request: Request, env: Env, ctx: ExecutionCont
743
747
  });
744
748
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
745
749
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
750
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
746
751
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
747
752
  }
748
753
 
@@ -3879,11 +3884,13 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3879
3884
  }
3880
3885
  if (isWorkflowDispatchJob(message.body)) {
3881
3886
  await processWorkflowDispatchJob(env, ctx, message.body);
3887
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3882
3888
  message.ack();
3883
3889
  continue;
3884
3890
  }
3885
3891
  if (isEvidenceFetchJob(message.body)) {
3886
3892
  await processEvidenceFetch(env, message.body);
3893
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3887
3894
  message.ack();
3888
3895
  continue;
3889
3896
  }
@@ -3907,6 +3914,7 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3907
3914
  await Promise.all(jobs.filter(isAiJob).map((job) => markAiJobEnqueued(env.DB, job.jobId, false)));
3908
3915
  }
3909
3916
  }
3917
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3910
3918
  message.ack();
3911
3919
  } catch (error) {
3912
3920
  await markJobEnqueueFailed(env.DB, message.body.jobId, "routing_processing_failed");
@@ -3933,16 +3941,19 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3933
3941
  });
3934
3942
  }
3935
3943
  }
3944
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3936
3945
  message.ack();
3937
3946
  continue;
3938
3947
  }
3939
3948
  if (isDeliveryJob(message.body)) {
3940
3949
  await processDeliveryJob(env.DB, message.body, env);
3950
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3941
3951
  message.ack();
3942
3952
  continue;
3943
3953
  }
3944
3954
  if (isActionProposalJob(message.body)) {
3945
3955
  await processActionProposalJob(env, message.body);
3956
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3946
3957
  message.ack();
3947
3958
  continue;
3948
3959
  }
@@ -4142,6 +4153,11 @@ export default {
4142
4153
  await handleQueue(batch, env, ctx);
4143
4154
  },
4144
4155
 
4156
+ async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
4157
+ const result = await receiveReporterEmail(message, env);
4158
+ if (result.stored && result.reportId) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.reportId });
4159
+ },
4160
+
4145
4161
  async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
4146
4162
  ctx.waitUntil((async () => {
4147
4163
  await Promise.all([
@@ -2,7 +2,9 @@ import { canonicalJson } from "./audit";
2
2
  import type { DeliveryJob } from "./report-types";
3
3
  import { decryptReportContact } from "./report-crypto";
4
4
  import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
5
- import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix } from "./report-email";
5
+ import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix, reporterReplyAddress } from "./report-email";
6
+ import { renderReporterEmail } from "./report-email-template";
7
+ import { loadWorkspaceBrand } from "./workspace-branding";
6
8
 
7
9
  const MAX_ATTEMPTS = 8;
8
10
  const RESPONSE_LIMIT = 16 * 1024;
@@ -41,6 +43,7 @@ interface EmailDeliveryRow {
41
43
  recipientReference: string | null;
42
44
  contactCiphertext: string | null;
43
45
  body: string;
46
+ templateVersionId: string | null;
44
47
  reportReference: string;
45
48
  installationId: string;
46
49
  queueId: string | null;
@@ -227,7 +230,7 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
227
230
  SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
228
231
  e.audience, e.state, e.attempt_count AS attemptCount,
229
232
  p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
230
- m.body, r.public_reference AS reportReference,
233
+ m.body, m.template_version_id AS templateVersionId, r.public_reference AS reportReference,
231
234
  r.installation_id AS installationId, r.queue_id AS queueId
232
235
  FROM email_deliveries e
233
236
  JOIN case_messages m ON m.id = e.message_id
@@ -486,11 +489,30 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
486
489
  throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
487
490
  }
488
491
  const prefix = reporterEmailSubjectPrefix(env);
492
+ const [brand, replyTo] = await Promise.all([
493
+ loadWorkspaceBrand(db),
494
+ reporterReplyAddress({
495
+ fromAddress: from,
496
+ publicReference: row.reportReference,
497
+ signingSecret: env.CONTEXT_SIGNING_SECRET,
498
+ expiresAt: Math.floor(Date.now() / 1_000) + Math.max(
499
+ Number.parseInt(env.REPORT_RETENTION_DAYS, 10) || 365,
500
+ Number.parseInt(env.APPEAL_WINDOW_DAYS, 10) || 30,
501
+ ) * 86_400,
502
+ }),
503
+ ]);
504
+ const rendered = renderReporterEmail({
505
+ brand, body: row.body, reportReference: row.reportReference,
506
+ templateVersionId: row.templateVersionId, subjectPrefix: prefix,
507
+ });
489
508
  const result = await binding.send({
490
509
  to: email,
491
- from,
492
- subject: `${prefix} — ${row.reportReference}`,
493
- text: `${row.body}\n\nReport reference: ${row.reportReference}`,
510
+ from: { email: from, name: `${brand.organization_name} Safety` },
511
+ replyTo: { email: replyTo, name: `${brand.organization_name} Safety` },
512
+ subject: rendered.subject,
513
+ text: rendered.text,
514
+ html: rendered.html,
515
+ headers: { "X-Safest-Report-Reference": row.reportReference },
494
516
  });
495
517
  providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
496
518
  } catch (error) {