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 +2 -2
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/template/README.md +1 -1
- package/template/package.json +6 -2
- package/template/scripts/reports-deploy.mjs +108 -29
- package/template/src/report-better-auth.ts +37 -36
- package/template/src/report-delivery.ts +2 -1
- package/template/src/report-email-inbound.ts +6 -5
- package/template/src/report-email-template.ts +257 -31
- package/template/src/report-email.ts +26 -10
- package/template/src/report-repository.ts +2 -1
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
|
|
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
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: "
|
|
161
|
+
REPORTER_EMAIL_SUBJECT_PREFIX: "",
|
|
162
162
|
},
|
|
163
163
|
triggers: { crons: ["*/5 * * * *"] },
|
|
164
164
|
};
|
package/template/README.md
CHANGED
|
@@ -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
|
|
package/template/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "safest-resolve-installation",
|
|
3
|
-
"version": "0.5.
|
|
4
|
-
"safestToolsVersion": "0.5.
|
|
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], {
|
|
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
|
|
54
|
-
return
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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(
|
|
161
|
-
|
|
162
|
-
|
|
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 (
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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:
|
|
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 (${
|
|
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:
|
|
290
|
+
from: { name: brand.organization_name, email: env.AUTH_EMAIL_FROM },
|
|
284
291
|
to: message.to,
|
|
285
|
-
subject:
|
|
286
|
-
text:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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,
|
|
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,
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
5
16
|
.replaceAll('"', """).replaceAll("'", "'");
|
|
6
17
|
}
|
|
7
18
|
|
|
8
|
-
function
|
|
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)}͏‌ ͏‌ ͏‌ </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"> </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
|
-
|
|
21
|
-
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
:
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
:
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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\
|
|
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
|
-
|| "
|
|
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
|
-
|
|
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
|
|
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
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
const
|
|
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 = `
|
|
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 {
|
|
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 =
|
|
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));
|