create-safest-tools 0.4.1 → 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 +52 -3
- package/template/console/reports/controller.ts +36 -5
- package/template/console/reports/types.ts +3 -0
- package/template/console/shell/controller.ts +8 -3
- package/template/package.json +3 -2
- package/template/public/console/auth-shell.js +4339 -4247
- 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 +24 -2
- 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
- package/template/src/report-workflow.ts +36 -2
- package/template/worker-configuration.d.ts +3 -3
|
@@ -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
|
|
|
@@ -2595,7 +2600,10 @@ async function handleAdminAiQualitySample(
|
|
|
2595
2600
|
const sample = await loadAiQualitySample(env.DB, sampleId);
|
|
2596
2601
|
if (!sample) throw new ApiError(404, "quality_sample_not_found", "The quality sample does not exist.");
|
|
2597
2602
|
await assertOperatorReportAccess(env.DB, session, String(sample.reportId));
|
|
2598
|
-
const report = await loadReportDetail(env.DB, String(sample.reportId)
|
|
2603
|
+
const report = await loadReportDetail(env.DB, String(sample.reportId), {
|
|
2604
|
+
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
2605
|
+
includeReporterEmail: session.permissions.messageReporters,
|
|
2606
|
+
});
|
|
2599
2607
|
if (!report) throw new ApiError(404, "report_not_found", "The sampled report does not exist.");
|
|
2600
2608
|
return json({ schema_version: "1", sample: projectOperatorFields(session, "quality_sample", sample), report: projectOperatorFields(session, "report", report) });
|
|
2601
2609
|
}
|
|
@@ -2843,7 +2851,10 @@ async function handleAdminReportDetail(request: Request, env: Env, reportId: str
|
|
|
2843
2851
|
if (request.method !== "GET") return methodNotAllowed(["GET"]);
|
|
2844
2852
|
const session = await requireOperator(request, env, "readReports");
|
|
2845
2853
|
await assertOperatorReportAccess(env.DB, session, reportId);
|
|
2846
|
-
const report = await loadReportDetail(env.DB, reportId
|
|
2854
|
+
const report = await loadReportDetail(env.DB, reportId, {
|
|
2855
|
+
contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
|
|
2856
|
+
includeReporterEmail: session.permissions.messageReporters,
|
|
2857
|
+
});
|
|
2847
2858
|
if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
|
|
2848
2859
|
return json({ schema_version: "1", report: projectOperatorFields(session, "report", report) });
|
|
2849
2860
|
}
|
|
@@ -3873,11 +3884,13 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3873
3884
|
}
|
|
3874
3885
|
if (isWorkflowDispatchJob(message.body)) {
|
|
3875
3886
|
await processWorkflowDispatchJob(env, ctx, message.body);
|
|
3887
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3876
3888
|
message.ack();
|
|
3877
3889
|
continue;
|
|
3878
3890
|
}
|
|
3879
3891
|
if (isEvidenceFetchJob(message.body)) {
|
|
3880
3892
|
await processEvidenceFetch(env, message.body);
|
|
3893
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3881
3894
|
message.ack();
|
|
3882
3895
|
continue;
|
|
3883
3896
|
}
|
|
@@ -3901,6 +3914,7 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3901
3914
|
await Promise.all(jobs.filter(isAiJob).map((job) => markAiJobEnqueued(env.DB, job.jobId, false)));
|
|
3902
3915
|
}
|
|
3903
3916
|
}
|
|
3917
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3904
3918
|
message.ack();
|
|
3905
3919
|
} catch (error) {
|
|
3906
3920
|
await markJobEnqueueFailed(env.DB, message.body.jobId, "routing_processing_failed");
|
|
@@ -3927,16 +3941,19 @@ async function handleQueue(batch: MessageBatch<unknown>, env: Env, ctx: Executio
|
|
|
3927
3941
|
});
|
|
3928
3942
|
}
|
|
3929
3943
|
}
|
|
3944
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId: message.body.reportId });
|
|
3930
3945
|
message.ack();
|
|
3931
3946
|
continue;
|
|
3932
3947
|
}
|
|
3933
3948
|
if (isDeliveryJob(message.body)) {
|
|
3934
3949
|
await processDeliveryJob(env.DB, message.body, env);
|
|
3950
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3935
3951
|
message.ack();
|
|
3936
3952
|
continue;
|
|
3937
3953
|
}
|
|
3938
3954
|
if (isActionProposalJob(message.body)) {
|
|
3939
3955
|
await processActionProposalJob(env, message.body);
|
|
3956
|
+
publishWorkspaceEvent(env, ctx, { type: "reports_changed" });
|
|
3940
3957
|
message.ack();
|
|
3941
3958
|
continue;
|
|
3942
3959
|
}
|
|
@@ -4136,6 +4153,11 @@ export default {
|
|
|
4136
4153
|
await handleQueue(batch, env, ctx);
|
|
4137
4154
|
},
|
|
4138
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
|
+
|
|
4139
4161
|
async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
|
4140
4162
|
ctx.waitUntil((async () => {
|
|
4141
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
|
+
}
|