create-safest-tools 0.4.2 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,7 @@
11
11
  <link rel="manifest" href="./manifest.webmanifest" />
12
12
  <link rel="stylesheet" href="./styles.css" />
13
13
  </head>
14
- <body class="login-mode">
14
+ <body class="auth-resolving">
15
15
  <svg class="svg-defs" aria-hidden="true">
16
16
  <defs>
17
17
  <symbol id="i-home" viewBox="0 0 24 24"><path d="m3 10 9-7 9 7v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/><path d="M9 21v-8h6v8"/></symbol>
@@ -43,7 +43,15 @@
43
43
  </div>
44
44
 
45
45
  <main id="main">
46
- <section id="login" class="login-layout" aria-labelledby="login-title">
46
+ <section id="session-loading" class="session-loading" aria-live="polite" aria-busy="true">
47
+ <div class="session-loading-card">
48
+ <span class="session-loading-mark" aria-hidden="true"></span>
49
+ <strong>Opening your workspace</strong>
50
+ <span>Restoring your secure session…</span>
51
+ </div>
52
+ </section>
53
+
54
+ <section id="login" class="login-layout" aria-labelledby="login-title" hidden>
47
55
  <div class="login-story">
48
56
  <p class="eyebrow light">Safest Resolve</p>
49
57
  <h1>Every report gets<br />a real next step.</h1>
@@ -1107,6 +1107,81 @@ body.report-open .content { margin-right: 0; }
1107
1107
  /* Authoritative responsive contract for the console shell and dense editors. */
1108
1108
  body.login-mode .topbar { display: flex; justify-content: flex-start; }
1109
1109
  body.login-mode .top-actions { margin-left: auto; }
1110
+ .session-loading {
1111
+ min-height: calc(100vh - 72px);
1112
+ display: grid;
1113
+ place-items: center;
1114
+ padding: 32px;
1115
+ background: var(--background, #f4f7f3);
1116
+ }
1117
+ .session-loading[hidden] { display: none; }
1118
+ .session-loading-card {
1119
+ display: grid;
1120
+ justify-items: center;
1121
+ gap: 8px;
1122
+ color: var(--text, #183228);
1123
+ text-align: center;
1124
+ }
1125
+ .session-loading-card strong { font-size: 16px; }
1126
+ .session-loading-card > span:last-child { color: var(--muted, #66766f); font-size: 13px; }
1127
+ .session-loading-mark {
1128
+ width: 30px;
1129
+ height: 30px;
1130
+ margin-bottom: 4px;
1131
+ border: 3px solid color-mix(in srgb, var(--primary, #165b43) 18%, transparent);
1132
+ border-top-color: var(--primary, #165b43);
1133
+ border-radius: 50%;
1134
+ animation: session-loading-spin .8s linear infinite;
1135
+ }
1136
+ @keyframes session-loading-spin { to { transform: rotate(360deg); } }
1137
+ @media (prefers-reduced-motion: reduce) { .session-loading-mark { animation: none; } }
1138
+
1139
+ /* Report drawer: readable case record with technical execution kept secondary. */
1140
+ .report-drawer .report-detail-content {
1141
+ display: block;
1142
+ max-width: 1180px;
1143
+ margin-inline: auto;
1144
+ padding: 22px 24px 36px;
1145
+ }
1146
+ .report-drawer .report-detail-content > section,
1147
+ .report-drawer .detail-grid section,
1148
+ .report-drawer .detail-actions form {
1149
+ border-color: #dce5de;
1150
+ border-radius: 12px;
1151
+ background: #fff;
1152
+ }
1153
+ .report-drawer .report-detail-content > section { margin-top: 16px; padding: 20px; }
1154
+ .report-drawer .detail-grid { grid-template-columns: 1fr 1fr; gap: 16px; }
1155
+ .report-drawer .claim-bar,
1156
+ .report-drawer .detail-actions,
1157
+ .report-drawer .surface-error { width: 100%; }
1158
+ .report-drawer .detail-actions { grid-template-columns: minmax(0, .85fr) minmax(0, 1.15fr); align-items: start; gap: 16px; }
1159
+ .case-activity-heading { display: flex; justify-content: space-between; align-items: flex-start; gap: 16px; margin-bottom: 20px; }
1160
+ .case-activity-heading h3 { margin: 0 !important; font-size: 20px; }
1161
+ .case-activity-heading p { margin: 5px 0 0; color: var(--muted); font-size: 12px; line-height: 1.5; }
1162
+ .case-activity-heading > span { flex: 0 0 auto; padding: 5px 9px; border-radius: 999px; color: #315b47; background: #edf5ef; font-size: 11px; font-weight: 750; }
1163
+ .case-activity-section .report-timeline { padding-left: 2px; }
1164
+ .case-activity-section .timeline-event { grid-template-columns: 36px minmax(0, 1fr); gap: 12px; min-height: 76px; }
1165
+ .case-activity-section .timeline-event:not(:last-child)::before { top: 32px; bottom: 0; left: 15px; width: 2px; background: #dfe8e1; }
1166
+ .case-activity-section .timeline-marker { width: 32px; height: 32px; border-width: 2px; font-size: 13px; }
1167
+ .case-activity-section .timeline-event > div { padding: 2px 0 18px; }
1168
+ .case-activity-section .timeline-event strong { color: #172d23; font-size: 13px; line-height: 1.35; }
1169
+ .case-activity-section .timeline-event span { margin-top: 4px; color: #40584c; font-size: 12px; line-height: 1.5; }
1170
+ .case-activity-section .timeline-event small { margin-top: 6px; color: #75827b; font-size: 10px; line-height: 1.4; }
1171
+ .workflow-technical { margin-top: 4px; border-top: 1px solid #e4eae5; }
1172
+ .workflow-technical summary { padding: 15px 2px 5px; color: #40564b; cursor: pointer; font-size: 12px; font-weight: 750; }
1173
+ .workflow-technical summary span { margin-left: 6px; color: var(--muted); font-weight: 550; }
1174
+ .workflow-technical .report-run-list { margin: 10px 0 0; }
1175
+ .technical-event-count { margin: 10px 2px 2px; color: var(--muted); font-size: 10px; }
1176
+ .report-drawer .case-message { max-width: min(82%, 720px); padding: 13px 15px; font-size: 12px; line-height: 1.55; }
1177
+ .report-drawer .case-message small { font-size: 10px; line-height: 1.45; }
1178
+
1179
+ @media (max-width: 800px) {
1180
+ .report-drawer .report-detail-content { padding: 14px 14px 28px; }
1181
+ .report-drawer .detail-grid,
1182
+ .report-drawer .detail-actions { grid-template-columns: 1fr; }
1183
+ .report-drawer .submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
1184
+ }
1110
1185
 
1111
1186
  @media (max-width: 760px) {
1112
1187
  body { min-width: 0; overflow-x: clip; }
@@ -48,7 +48,7 @@
48
48
  "required": ["enabled", "fromAddress"],
49
49
  "properties": {
50
50
  "enabled": { "type": "boolean" },
51
- "fromAddress": { "type": "string", "maxLength": 254 },
51
+ "fromAddress": { "type": "string", "maxLength": 254, "description": "Receivable reporter mailbox. Resolve routes signed plus-addressed replies for this exact address to the Worker." },
52
52
  "authFromAddress": { "type": "string", "maxLength": 254 }
53
53
  }
54
54
  },
@@ -66,6 +66,55 @@ async function fetchWorkersAccountSettings(accountId, token, fetchImpl) {
66
66
  return { ok: true, settings: body.result };
67
67
  }
68
68
 
69
+ async function cloudflareApi(path, token, fetchImpl, init = {}) {
70
+ const response = await fetchImpl(`https://api.cloudflare.com/client/v4${path}`, {
71
+ ...init,
72
+ headers: {
73
+ authorization: `Bearer ${token}`,
74
+ accept: "application/json",
75
+ ...(init.body ? { "content-type": "application/json" } : {}),
76
+ ...init.headers,
77
+ },
78
+ });
79
+ const body = await response.json().catch(() => null);
80
+ if (!response.ok || body?.success !== true) {
81
+ const detail = Array.isArray(body?.errors) && body.errors[0]?.message ? `: ${body.errors[0].message}` : "";
82
+ throw new Error(`Cloudflare API ${response.status}${detail}`);
83
+ }
84
+ return body.result;
85
+ }
86
+
87
+ async function emailZone(domain, token, fetchImpl) {
88
+ const labels = domain.toLowerCase().split(".").filter(Boolean);
89
+ for (let index = 0; index <= Math.max(0, labels.length - 2); index += 1) {
90
+ const candidate = labels.slice(index).join(".");
91
+ const zones = await cloudflareApi(`/zones?name=${encodeURIComponent(candidate)}&status=active&per_page=5`, token, fetchImpl);
92
+ if (Array.isArray(zones) && zones.length === 1 && typeof zones[0]?.id === "string") return zones[0];
93
+ }
94
+ throw new Error(`Cloudflare could not find an active zone that owns the reporter email domain ${domain}.`);
95
+ }
96
+
97
+ export async function ensureReporterReplyRouting({ reporterAddress, wranglerToken, fetchImpl = fetch }) {
98
+ const separator = reporterAddress.lastIndexOf("@");
99
+ if (separator < 1) throw new Error("The reporter email address is invalid.");
100
+ const domain = reporterAddress.slice(separator + 1).toLowerCase();
101
+ const zone = await emailZone(domain, wranglerToken, fetchImpl);
102
+ const settings = await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl);
103
+ if (settings?.enabled !== true || settings?.status !== "ready") {
104
+ throw new Error(
105
+ `Cloudflare Email Routing is not ready for ${zone.name}. Enable it before setup so ${reporterAddress} can receive case replies; ` +
106
+ "Resolve will not change a domain's MX records automatically.",
107
+ );
108
+ }
109
+ if (settings.support_subaddress !== true) {
110
+ await cloudflareApi(`/zones/${encodeURIComponent(zone.id)}/email/routing`, wranglerToken, fetchImpl, {
111
+ method: "PATCH",
112
+ body: JSON.stringify({ support_subaddress: true }),
113
+ });
114
+ }
115
+ return { zoneId: zone.id, zoneName: zone.name, address: reporterAddress, subaddressingEnabled: true };
116
+ }
117
+
69
118
  export function isWorkersPaidUsageModel(value) {
70
119
  return String(value ?? "").trim().toLowerCase() === "standard";
71
120
  }
@@ -6,6 +6,7 @@ import { fileURLToPath } from "node:url";
6
6
  import { configureAuthentication, requiredOAuthSecrets } from "./reports-auth-onboarding.mjs";
7
7
  import {
8
8
  chooseCloudflareAccount,
9
+ ensureReporterReplyRouting,
9
10
  ensureWranglerAuthentication,
10
11
  saveCloudflareAccountSelection,
11
12
  verifyWorkersPaid,
@@ -138,6 +139,11 @@ export async function deployReports(argv = process.argv.slice(2)) {
138
139
  }
139
140
  await validateSecrets(secretsPath, config);
140
141
  await confirmation(config.installationId, options.yes);
142
+ const reporterRouting = await ensureReporterReplyRouting({
143
+ reporterAddress: config.email.fromAddress,
144
+ wranglerToken: authentication.token,
145
+ });
146
+ console.log(`\nReporter replies will route through ${reporterRouting.address}; plus-addressing is enabled and no catch-all is created.`);
141
147
  const databaseConfigured = await hasDatabaseId();
142
148
  if (options.upgrade && !databaseConfigured) throw new Error("upgrade requires an existing D1 database_id in wrangler.jsonc");
143
149
  if (!options.upgrade && databaseConfigured) throw new Error("this project already has a D1 database_id; use npm run upgrade instead of setup");
@@ -85,6 +85,13 @@ export function parseReportsConfig(value) {
85
85
  const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
86
86
  const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
87
87
  if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
88
+ const emailLocal = emailFromAddress.slice(0, emailFromAddress.lastIndexOf("@"));
89
+ if (email.enabled && /^no-?reply$/u.test(emailLocal)) {
90
+ throw new ReportsConfigError("email.fromAddress must be a receivable mailbox, not a no-reply address");
91
+ }
92
+ if (email.enabled && (emailLocal.includes("+") || emailLocal.length > 10)) {
93
+ throw new ReportsConfigError("email.fromAddress must use a base mailbox of 10 characters or fewer without plus-addressing so replies can be routed securely");
94
+ }
88
95
  if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
89
96
  if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
90
97
  if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
@@ -156,6 +163,11 @@ export function buildReportsPlan(config) {
156
163
  reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
157
164
  authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
158
165
  },
166
+ inboundReporterEmail: {
167
+ address: config.email.fromAddress,
168
+ routingRule: `Exact ${config.email.fromAddress} address routed to this Worker (no catch-all)`,
169
+ subaddressing: "Enabled so signed per-case reply addresses are accepted",
170
+ },
159
171
  staticAssets: "public/",
160
172
  publicHostname,
161
173
  customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
@@ -184,7 +196,7 @@ export function buildReportsPlan(config) {
184
196
  },
185
197
  order: [
186
198
  "Review this plan and the initial infrastructure-owner email.",
187
- "Onboard the sender domain to Cloudflare Email Service before deployment.",
199
+ "Onboard the sender domain to Cloudflare Email Service and enable Email Routing for the reporter address before deployment.",
188
200
  "Initialize local secrets without committing them.",
189
201
  "Create D1, four private R2 buckets, and the three Queues in the customer account.",
190
202
  "Apply the D1 schema baseline and any later forward-only migrations before accepting traffic.",
@@ -56,6 +56,7 @@ import { sha256, signContextToken, verifyContextToken } from "./report-crypto";
56
56
  import { createStoredReportContext, loadStoredReportContext, pruneReportContexts } from "./report-context";
57
57
  import { parseContextSchema, routingContextFacts, validateContextFacts } from "./report-context-schema";
58
58
  import { pendingDeliveryJobs, processDeliveryJob, RetryableDeliveryError } from "./report-delivery";
59
+ import { receiveReporterEmail } from "./report-email-inbound";
59
60
  import { ApiError, allowedOrigin, apiErrorResponse, json, methodNotAllowed, readJsonObject, withCors } from "./report-http";
60
61
  import {
61
62
  cancelHumanTask,
@@ -613,6 +614,7 @@ async function handleWidgetReport(request: Request, env: Env, ctx: ExecutionCont
613
614
  });
614
615
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
615
616
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
617
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
616
618
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
617
619
  }
618
620
 
@@ -649,6 +651,7 @@ async function handleAnonymousWidgetReport(request: Request, env: Env, ctx: Exec
649
651
  });
650
652
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
651
653
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
654
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
652
655
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
653
656
  }
654
657
 
@@ -670,6 +673,7 @@ async function handleServerReport(request: Request, env: Env, ctx: ExecutionCont
670
673
  });
671
674
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
672
675
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
676
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
673
677
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
674
678
  }
675
679
 
@@ -743,6 +747,7 @@ async function handlePublicReport(request: Request, env: Env, ctx: ExecutionCont
743
747
  });
744
748
  if (result.route) queueRoutingJob(env, ctx, result.jobId, result.receipt.reportId);
745
749
  queueDeliveryJobs(env, ctx, result.deliveryJobs);
750
+ if (result.created) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.receipt.reportId });
746
751
  return json(receiptJson(result.receipt), { status: result.created ? 201 : 200 });
747
752
  }
748
753
 
@@ -3879,11 +3884,13 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3879
3884
  }
3880
3885
  if (isWorkflowDispatchJob(message.body)) {
3881
3886
  await processWorkflowDispatchJob(env, ctx, message.body);
3887
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3882
3888
  message.ack();
3883
3889
  continue;
3884
3890
  }
3885
3891
  if (isEvidenceFetchJob(message.body)) {
3886
3892
  await processEvidenceFetch(env, message.body);
3893
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3887
3894
  message.ack();
3888
3895
  continue;
3889
3896
  }
@@ -3907,6 +3914,7 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3907
3914
  await Promise.all(jobs.filter(isAiJob).map((job) => markAiJobEnqueued(env.DB, job.jobId, false)));
3908
3915
  }
3909
3916
  }
3917
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3910
3918
  message.ack();
3911
3919
  } catch (error) {
3912
3920
  await markJobEnqueueFailed(env.DB, message.body.jobId, "routing_processing_failed");
@@ -3933,16 +3941,19 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
3933
3941
  });
3934
3942
  }
3935
3943
  }
3944
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
3936
3945
  message.ack();
3937
3946
  continue;
3938
3947
  }
3939
3948
  if (isDeliveryJob(message.body)) {
3940
3949
  await processDeliveryJob(env.DB, message.body, env);
3950
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3941
3951
  message.ack();
3942
3952
  continue;
3943
3953
  }
3944
3954
  if (isActionProposalJob(message.body)) {
3945
3955
  await processActionProposalJob(env, message.body);
3956
+ publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
3946
3957
  message.ack();
3947
3958
  continue;
3948
3959
  }
@@ -4142,6 +4153,11 @@ export default {
4142
4153
  await handleQueue(batch, env, ctx);
4143
4154
  },
4144
4155
 
4156
+ async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise<void> {
4157
+ const result = await receiveReporterEmail(message, env);
4158
+ if (result.stored && result.reportId) publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: result.reportId });
4159
+ },
4160
+
4145
4161
  async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
4146
4162
  ctx.waitUntil((async () => {
4147
4163
  await Promise.all([
@@ -2,7 +2,9 @@ import { canonicalJson } from "./audit";
2
2
  import type { DeliveryJob } from "./report-types";
3
3
  import { decryptReportContact } from "./report-crypto";
4
4
  import { BudgetControlError, reserveBudgets, settleBudgets } from "./budget-control";
5
- import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix } from "./report-email";
5
+ import { reporterEmailBinding, reporterEmailFrom, reporterEmailSubjectPrefix, reporterReplyAddress } from "./report-email";
6
+ import { renderReporterEmail } from "./report-email-template";
7
+ import { loadWorkspaceBrand } from "./workspace-branding";
6
8
 
7
9
  const MAX_ATTEMPTS = 8;
8
10
  const RESPONSE_LIMIT = 16 * 1024;
@@ -41,6 +43,7 @@ interface EmailDeliveryRow {
41
43
  recipientReference: string | null;
42
44
  contactCiphertext: string | null;
43
45
  body: string;
46
+ templateVersionId: string | null;
44
47
  reportReference: string;
45
48
  installationId: string;
46
49
  queueId: string | null;
@@ -227,7 +230,7 @@ async function claimEmail(db: D1Database, id: string): Promise<EmailDeliveryRow
227
230
  SELECT e.id, m.report_id AS reportId, e.message_id AS messageId,
228
231
  e.audience, e.state, e.attempt_count AS attemptCount,
229
232
  p.reference AS recipientReference, p.contact_ciphertext AS contactCiphertext,
230
- m.body, r.public_reference AS reportReference,
233
+ m.body, m.template_version_id AS templateVersionId, r.public_reference AS reportReference,
231
234
  r.installation_id AS installationId, r.queue_id AS queueId
232
235
  FROM email_deliveries e
233
236
  JOIN case_messages m ON m.id = e.message_id
@@ -486,11 +489,30 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
486
489
  throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
487
490
  }
488
491
  const prefix = reporterEmailSubjectPrefix(env);
492
+ const [brand, replyTo] = await Promise.all([
493
+ loadWorkspaceBrand(db),
494
+ reporterReplyAddress({
495
+ fromAddress: from,
496
+ publicReference: row.reportReference,
497
+ signingSecret: env.CONTEXT_SIGNING_SECRET,
498
+ expiresAt: Math.floor(Date.now() / 1_000) + Math.max(
499
+ Number.parseInt(env.REPORT_RETENTION_DAYS, 10) || 365,
500
+ Number.parseInt(env.APPEAL_WINDOW_DAYS, 10) || 30,
501
+ ) * 86_400,
502
+ }),
503
+ ]);
504
+ const rendered = renderReporterEmail({
505
+ brand, body: row.body, reportReference: row.reportReference,
506
+ templateVersionId: row.templateVersionId, subjectPrefix: prefix,
507
+ });
489
508
  const result = await binding.send({
490
509
  to: email,
491
- from,
492
- subject: `${prefix} — ${row.reportReference}`,
493
- text: `${row.body}\n\nReport reference: ${row.reportReference}`,
510
+ from: { email: from, name: `${brand.organization_name} Safety` },
511
+ replyTo: { email: replyTo, name: `${brand.organization_name} Safety` },
512
+ subject: rendered.subject,
513
+ text: rendered.text,
514
+ html: rendered.html,
515
+ headers: { "X-Safest-Report-Reference": row.reportReference },
494
516
  });
495
517
  providerMessageId = typeof result.messageId === "string" ? result.messageId.slice(0, 300) : null;
496
518
  } catch (error) {
@@ -0,0 +1,169 @@
1
+ import PostalMime from "postal-mime";
2
+ import { canonicalJson } from "./audit";
3
+ import { decryptReportContact, secretsEqual, sha256 } from "./report-crypto";
4
+ import { reporterEmailFrom, verifyReporterReplyToken } from "./report-email";
5
+ import type { ReportState } from "./report-types";
6
+
7
+ const MAX_INBOUND_BYTES = 5 * 1024 * 1024;
8
+ const MAX_MESSAGE_LENGTH = 8_000;
9
+
10
+ interface ReplyReportRow {
11
+ id: string;
12
+ state: ReportState;
13
+ participantId: string;
14
+ contactCiphertext: string | null;
15
+ }
16
+
17
+ function now(): string {
18
+ return new Date().toISOString();
19
+ }
20
+
21
+ function htmlToText(value: string): string {
22
+ return value
23
+ .replace(/<(script|style)\b[^>]*>[\s\S]*?<\/\1>/giu, "")
24
+ .replace(/<\s*br\s*\/?>/giu, "\n")
25
+ .replace(/<\/(p|div|li|tr|h[1-6])\s*>/giu, "\n")
26
+ .replace(/<[^>]+>/gu, "")
27
+ .replaceAll("&nbsp;", " ")
28
+ .replaceAll("&amp;", "&")
29
+ .replaceAll("&lt;", "<")
30
+ .replaceAll("&gt;", ">")
31
+ .replaceAll("&quot;", '"')
32
+ .replaceAll("&#39;", "'");
33
+ }
34
+
35
+ export function readableReplyBody(text: string): string {
36
+ const normalized = text.replaceAll("\r\n", "\n").replaceAll("\r", "\n");
37
+ const kept: string[] = [];
38
+ for (const line of normalized.split("\n")) {
39
+ const trimmed = line.trim();
40
+ if (/^On .+wrote:$/iu.test(trimmed)
41
+ || /^-{2,}\s*(?:Original Message|Forwarded message)\s*-*$/iu.test(trimmed)
42
+ || (/^From:\s+/iu.test(trimmed) && kept.length > 0)) break;
43
+ if (/^>/u.test(trimmed)) continue;
44
+ kept.push(line.replace(/[ \t]+$/u, ""));
45
+ }
46
+ return kept.join("\n").replace(/\n{3,}/gu, "\n\n").trim().slice(0, MAX_MESSAGE_LENGTH);
47
+ }
48
+
49
+ function replyTokenFromRecipient(recipient: string, baseAddress: string): string | null {
50
+ const recipientAt = recipient.lastIndexOf("@");
51
+ const baseAt = baseAddress.lastIndexOf("@");
52
+ if (recipientAt < 1 || baseAt < 1) return null;
53
+ const local = recipient.slice(0, recipientAt).toLowerCase();
54
+ const domain = recipient.slice(recipientAt + 1).toLowerCase();
55
+ const baseLocal = baseAddress.slice(0, baseAt).toLowerCase();
56
+ const baseDomain = baseAddress.slice(baseAt + 1).toLowerCase();
57
+ if (domain !== baseDomain || !local.startsWith(`${baseLocal}+`)) return null;
58
+ return local.slice(baseLocal.length + 1);
59
+ }
60
+
61
+ async function recordUnmatchedEmail(db: D1Database, message: ForwardableEmailMessage, reason: string): Promise<void> {
62
+ const timestamp = now();
63
+ const fingerprint = await sha256(`inbound-email:${reason}:${message.to.toLowerCase()}:${message.from.toLowerCase()}:${message.headers.get("message-id") ?? ""}`);
64
+ await db.prepare(`
65
+ INSERT INTO operations_incidents (
66
+ id, installation_id, incident_key, severity, category, state, title, summary,
67
+ entity_type, entity_id, occurrence_count, first_seen_at, last_seen_at, metadata_json
68
+ ) VALUES (?1, 'default', ?2, 'low', 'inbound_email_unmatched', 'open',
69
+ 'Reporter email could not be matched', ?3, 'inbound_email', ?4, 1, ?5, ?5, ?6)
70
+ ON CONFLICT(installation_id, incident_key) DO UPDATE SET
71
+ state = 'open', occurrence_count = operations_incidents.occurrence_count + 1,
72
+ last_seen_at = excluded.last_seen_at, metadata_json = excluded.metadata_json,
73
+ resolved_by = NULL, resolved_at = NULL, resolution_summary = NULL
74
+ `).bind(
75
+ crypto.randomUUID(), `inbound-email:${fingerprint}`, `An inbound reporter email was not attached to a case (${reason}).`,
76
+ fingerprint.slice(0, 100), timestamp,
77
+ canonicalJson({ reason, recipientDomain: message.to.split("@").at(-1)?.toLowerCase() ?? "", rawSize: message.rawSize }),
78
+ ).run();
79
+ }
80
+
81
+ function nextState(state: ReportState): ReportState {
82
+ if (state === "awaiting_reporter") return "human_review";
83
+ if (["resolved_by_human", "resolved_by_ai", "closed"].includes(state)) return "human_review_requested";
84
+ return state;
85
+ }
86
+
87
+ export async function receiveReporterEmail(
88
+ message: ForwardableEmailMessage,
89
+ env: Env,
90
+ ): Promise<{ reportId: string | null; stored: boolean }> {
91
+ if (message.rawSize > MAX_INBOUND_BYTES) {
92
+ message.setReject("Reply is too large. Please send a text-only reply under 5 MB.");
93
+ return { reportId: null, stored: false };
94
+ }
95
+ const baseAddress = reporterEmailFrom(env);
96
+ const token = replyTokenFromRecipient(message.to, baseAddress);
97
+ const claim = token ? await verifyReporterReplyToken(token, env.CONTEXT_SIGNING_SECRET) : null;
98
+ if (!claim) {
99
+ await recordUnmatchedEmail(env.DB, message, token ? "invalid_or_expired_reply_token" : "reply_token_missing");
100
+ return { reportId: null, stored: false };
101
+ }
102
+ const report = await env.DB.prepare(`
103
+ SELECT r.id, r.state, p.id AS participantId, p.contact_ciphertext AS contactCiphertext
104
+ FROM reports r
105
+ JOIN report_participants p ON p.report_id = r.id AND p.audience = 'reporter'
106
+ WHERE r.public_reference = ?1
107
+ LIMIT 1
108
+ `).bind(claim.publicReference).first<ReplyReportRow>();
109
+ if (!report?.contactCiphertext) {
110
+ await recordUnmatchedEmail(env.DB, message, "report_or_reporter_contact_missing");
111
+ return { reportId: null, stored: false };
112
+ }
113
+ const expectedSender = (await decryptReportContact(
114
+ env.CREDENTIAL_ENCRYPTION_KEY, report.contactCiphertext, report.id, "reporter",
115
+ )).trim().toLowerCase();
116
+ if (!await secretsEqual(message.from.trim().toLowerCase(), expectedSender)) {
117
+ await recordUnmatchedEmail(env.DB, message, "sender_mismatch");
118
+ return { reportId: null, stored: false };
119
+ }
120
+ const parsed = await PostalMime.parse(message.raw, {
121
+ maxNestingDepth: 15,
122
+ maxHeadersSize: 256 * 1024,
123
+ maxRfc822NestingDepth: 3,
124
+ });
125
+ const body = readableReplyBody(parsed.text || (parsed.html ? htmlToText(parsed.html) : ""));
126
+ if (!body) {
127
+ await recordUnmatchedEmail(env.DB, message, "empty_reply_body");
128
+ return { reportId: report.id, stored: false };
129
+ }
130
+ const fingerprint = await sha256(canonicalJson({
131
+ messageId: parsed.messageId || message.headers.get("message-id") || "",
132
+ from: message.from.toLowerCase(),
133
+ reportId: report.id,
134
+ body,
135
+ }));
136
+ const idempotencyKey = `inbound-email:${fingerprint}`;
137
+ const existing = await env.DB.prepare(`SELECT id FROM case_messages WHERE report_id = ?1 AND idempotency_key = ?2 LIMIT 1`)
138
+ .bind(report.id, idempotencyKey).first<{ id: string }>();
139
+ if (existing) return { reportId: report.id, stored: false };
140
+ const receivedAt = now();
141
+ const targetState = nextState(report.state);
142
+ const messageId = crypto.randomUUID();
143
+ const statements: D1PreparedStatement[] = [
144
+ env.DB.prepare(`
145
+ INSERT OR IGNORE INTO case_messages (
146
+ id, report_id, audience, direction, sender_type, sender_id, body,
147
+ automated, delivery_state, created_at, idempotency_key, audience_checked_at
148
+ ) VALUES (?1, ?2, 'reporter', 'inbound', 'reporter', ?3, ?4, 0, 'not_required', ?5, ?6, ?5)
149
+ `).bind(messageId, report.id, report.participantId, body, receivedAt, idempotencyKey),
150
+ env.DB.prepare(`UPDATE reports SET state = ?2, updated_at = ?3 WHERE id = ?1`).bind(report.id, targetState, receivedAt),
151
+ env.DB.prepare(`
152
+ INSERT OR IGNORE INTO audit_events (
153
+ id, idempotency_key, action, actor_type, actor_id, target_type, target_id, details_json, created_at
154
+ ) VALUES (?1, ?2, 'report.reporter_reply_received', 'participant', ?3, 'report', ?4, ?5, ?6)
155
+ `).bind(
156
+ crypto.randomUUID(), `audit:${idempotencyKey}`, report.participantId, report.id,
157
+ canonicalJson({ messageId, previousState: report.state, nextState: targetState, source: "email" }), receivedAt,
158
+ ),
159
+ ];
160
+ if (targetState !== report.state) {
161
+ statements.push(env.DB.prepare(`
162
+ INSERT INTO report_state_history (
163
+ id, report_id, from_state, to_state, reason_code, actor_type, actor_id, created_at
164
+ ) VALUES (?1, ?2, ?3, ?4, 'reporter_reply_received', 'participant', ?5, ?6)
165
+ `).bind(crypto.randomUUID(), report.id, report.state, targetState, report.participantId, receivedAt));
166
+ }
167
+ await env.DB.batch(statements);
168
+ return { reportId: report.id, stored: true };
169
+ }
@@ -0,0 +1,53 @@
1
+ import type { WorkspaceBrand } from "./workspace-branding";
2
+
3
+ function escapeHtml(value: string): string {
4
+ return value.replaceAll("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;")
5
+ .replaceAll('"', "&quot;").replaceAll("'", "&#039;");
6
+ }
7
+
8
+ function kind(templateVersionId: string | null): "receipt" | "followup" | "outcome" {
9
+ if (templateVersionId?.includes("received")) return "receipt";
10
+ if (templateVersionId?.includes("outcome")) return "outcome";
11
+ return "followup";
12
+ }
13
+
14
+ export function renderReporterEmail(input: {
15
+ brand: WorkspaceBrand;
16
+ body: string;
17
+ reportReference: string;
18
+ templateVersionId: string | null;
19
+ subjectPrefix: string;
20
+ }): { subject: string; text: string; html: string } {
21
+ const messageKind = kind(input.templateVersionId);
22
+ const heading = messageKind === "receipt"
23
+ ? "We received your report"
24
+ : messageKind === "outcome" ? "An outcome is ready" : "A message from the safety team";
25
+ const preview = messageKind === "receipt"
26
+ ? `Your report ${input.reportReference} is now with the safety team.`
27
+ : `There is an update for report ${input.reportReference}.`;
28
+ const subject = messageKind === "receipt"
29
+ ? `${input.brand.organization_name} received your report — ${input.reportReference}`
30
+ : `${input.subjectPrefix} — ${input.reportReference}`;
31
+ const messageHtml = escapeHtml(input.body).replaceAll("\n", "<br />");
32
+ const logo = input.brand.logo_url
33
+ ? `<img src="${escapeHtml(input.brand.logo_url)}" width="40" alt="${escapeHtml(input.brand.logo_alt || input.brand.organization_name)}" style="display:block;max-width:120px;max-height:40px;border:0" />`
34
+ : `<span style="display:inline-block;width:36px;height:36px;line-height:36px;border-radius:10px;background:${input.brand.primary_color};color:${input.brand.primary_contrast_color};font-size:18px;font-weight:700;text-align:center">${escapeHtml(input.brand.organization_name.slice(0, 1).toUpperCase())}</span>`;
35
+ const html = `<!doctype html>
36
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${escapeHtml(subject)}</title></head>
37
+ <body style="margin:0;padding:0;background:${input.brand.background_color};color:${input.brand.text_color};font-family:Arial,Helvetica,sans-serif">
38
+ <div style="display:none;max-height:0;overflow:hidden;opacity:0">${escapeHtml(preview)}</div>
39
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="background:${input.brand.background_color}"><tr><td align="center" style="padding:32px 16px">
40
+ <table role="presentation" width="100%" cellspacing="0" cellpadding="0" border="0" style="max-width:600px;background:${input.brand.surface_color};border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 16)}px;overflow:hidden">
41
+ <tr><td style="height:6px;background:${input.brand.primary_color};font-size:0">&nbsp;</td></tr>
42
+ <tr><td style="padding:28px 32px 12px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td>${logo}</td><td align="right" style="color:${input.brand.muted_color};font-size:13px;font-weight:700">${escapeHtml(input.brand.organization_name)}</td></tr></table></td></tr>
43
+ <tr><td style="padding:12px 32px 8px"><h1 style="margin:0;font-size:26px;line-height:1.25;color:${input.brand.text_color};font-family:Georgia,'Times New Roman',serif">${escapeHtml(heading)}</h1></td></tr>
44
+ <tr><td style="padding:12px 32px"><div style="padding:20px;border:1px solid ${input.brand.border_color};border-radius:${Math.min(input.brand.border_radius, 14)}px;background:${input.brand.background_color};font-size:16px;line-height:1.6;color:${input.brand.text_color}">${messageHtml}</div></td></tr>
45
+ <tr><td style="padding:10px 32px 24px"><table role="presentation" width="100%" cellspacing="0" cellpadding="0"><tr><td style="color:${input.brand.muted_color};font-size:13px">Report reference</td><td align="right" style="color:${input.brand.text_color};font-size:13px;font-weight:700">${escapeHtml(input.reportReference)}</td></tr></table></td></tr>
46
+ <tr><td style="padding:20px 32px;border-top:1px solid ${input.brand.border_color};color:${input.brand.muted_color};font-size:12px;line-height:1.55">Reply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case. This message contains only the information needed for your report update.</td></tr>
47
+ </table></td></tr></table></body></html>`;
48
+ return {
49
+ subject,
50
+ html,
51
+ text: `${heading}\n\n${input.body}\n\nReport reference: ${input.reportReference}\n\nReply directly to this email to contact the safety team. Keep the reply address unchanged so your message reaches the correct case.`,
52
+ };
53
+ }