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.
- package/README.md +2 -2
- package/package.json +1 -1
- package/src/cli.mjs +2 -2
- package/src/config.mjs +13 -0
- package/template/README.md +2 -2
- package/template/console/reports/ReportDrawer.tsx +43 -3
- package/template/console/reports/controller.ts +36 -5
- package/template/console/shell/controller.ts +8 -3
- package/template/package.json +3 -2
- package/template/public/console/auth-shell.js +4335 -4250
- package/template/public/index.html +10 -2
- package/template/public/styles.css +75 -0
- package/template/reports.schema.json +1 -1
- package/template/scripts/reports-cloudflare-preflight.mjs +49 -0
- package/template/scripts/reports-deploy.mjs +6 -0
- package/template/scripts/reports-plan.mjs +13 -1
- package/template/src/index.ts +16 -0
- package/template/src/report-delivery.ts +27 -5
- package/template/src/report-email-inbound.ts +169 -0
- package/template/src/report-email-template.ts +53 -0
- package/template/src/report-email.ts +71 -2
- package/template/src/report-presence.ts +9 -5
- package/template/src/report-repository.ts +1 -1
- package/template/src/report-workflow-validation.ts +15 -1
|
@@ -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="
|
|
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="
|
|
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.",
|
package/template/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
493
|
-
|
|
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(" ", " ")
|
|
28
|
+
.replaceAll("&", "&")
|
|
29
|
+
.replaceAll("<", "<")
|
|
30
|
+
.replaceAll(">", ">")
|
|
31
|
+
.replaceAll(""", '"')
|
|
32
|
+
.replaceAll("'", "'");
|
|
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("&", "&").replaceAll("<", "<").replaceAll(">", ">")
|
|
5
|
+
.replaceAll('"', """).replaceAll("'", "'");
|
|
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"> </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
|
+
}
|