create-safest-tools 0.5.1 → 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 +1 -1
- package/package.json +1 -1
- package/src/config.mjs +1 -1
- package/template/package.json +2 -2
- 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
|
|
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/package.json
CHANGED
|
@@ -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));
|