create-safest-tools 0.4.0 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -256,6 +256,19 @@ td small { margin-top: 3px; color: var(--muted); }
256
256
  .detail-grid dl { margin: 0; display: grid; grid-template-columns: 115px 1fr; gap: 8px; font-size: 10px; }
257
257
  .detail-grid dt { color: var(--muted); }
258
258
  .detail-grid dd { margin: 0; overflow-wrap: anywhere; font-weight: 750; }
259
+ .report-submission { grid-column: 1 / -1; }
260
+ .submission-meta { margin-bottom: 14px !important; grid-template-columns: 115px minmax(0, 1fr) 115px minmax(0, 1fr) !important; }
261
+ .answer-list { display: grid; gap: 8px; }
262
+ .answer-record { padding: 11px 12px; display: grid; grid-template-columns: minmax(150px, .8fr) minmax(0, 1.4fr); gap: 16px; align-items: start; border: 1px solid #e5e9e4; border-radius: 8px; background: white; font-size: 11px; line-height: 1.5; }
263
+ .answer-record strong, .answer-record small { display: block; }
264
+ .answer-record strong { font-size: 11px; }
265
+ .answer-record small { margin-top: 3px; color: var(--muted); font-size: 9px; }
266
+ .answer-record > :last-child { min-width: 0; overflow-wrap: anywhere; }
267
+ .answer-text { white-space: pre-wrap; }
268
+ .answer-empty { color: var(--muted); font-style: italic; }
269
+ .form-help { margin: -5px 0 12px; color: var(--muted); font-size: 10px; line-height: 1.45; }
270
+ .mutation-status { margin: 0; padding: 11px 12px; border: 1px solid #d4e7d9; border-radius: 8px; color: #23543b; background: #eff8f1; font-size: 10px; line-height: 1.45; }
271
+ .mutation-status:empty { display: none; }
259
272
  .evidence-list { display: grid; gap: 7px; }
260
273
  .evidence-item { padding: 8px; border-radius: 7px; color: #405249; background: white; font-size: 9px; overflow-wrap: anywhere; }
261
274
  .report-detail-content > section { margin-top: 14px; }
@@ -845,8 +858,8 @@ body.report-open .content { margin-right: 0; }
845
858
  .sign-out-button:hover { background: #f7f9f6; }
846
859
 
847
860
  .workspace { min-height: calc(100vh - var(--product-topbar)); grid-template-columns: var(--product-sidebar) minmax(0, 1fr); background: var(--canvas); }
848
- .sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); padding: 16px 12px 14px; background: var(--forest-dark); }
849
- .sidebar nav { margin-top: 0; display: grid; gap: 3px; }
861
+ .sidebar { top: var(--product-topbar); height: calc(100vh - var(--product-topbar)); height: calc(100dvh - var(--product-topbar)); min-height: 0; padding: 16px 12px 14px; overflow-y: auto; overscroll-behavior: contain; scrollbar-gutter: stable; scrollbar-width: thin; background: var(--forest-dark); }
862
+ .sidebar nav { min-height: min-content; margin-top: 0; padding-bottom: 10px; display: grid; gap: 3px; }
850
863
  .sidebar nav > small { margin: 11px 10px 5px; color: #7fa596; font-size: 9px; font-weight: 800; letter-spacing: .11em; text-transform: uppercase; }
851
864
  .sidebar nav > small:first-child { margin-top: 9px; }
852
865
  .sidebar nav a { min-height: 40px; padding: 0 10px; display: grid; grid-template-columns: 20px minmax(0, 1fr) auto; gap: 10px; color: #c5d7cf; font-size: 13px; font-weight: 560; }
@@ -1490,6 +1503,8 @@ body.login-mode .top-actions { margin-left: auto; }
1490
1503
  .configuration-dialog .detail-head, .registry-dialog .detail-head { padding: 14px; }
1491
1504
  .configuration-dialog .editor-form, .registry-dialog .editor-form { padding: 14px; }
1492
1505
  .configuration-tabs { scrollbar-width: thin; }
1506
+ .submission-meta { grid-template-columns: 100px minmax(0, 1fr) !important; }
1507
+ .answer-record { grid-template-columns: 1fr; gap: 5px; }
1493
1508
  .configuration-tabs button { flex: 0 0 auto; }
1494
1509
  }
1495
1510
 
@@ -21,7 +21,8 @@
21
21
  "owner": { "email": "infrastructure@example.com" },
22
22
  "email": {
23
23
  "enabled": true,
24
- "fromAddress": "reports@example.com"
24
+ "fromAddress": "reports@example.com",
25
+ "authFromAddress": "accounts@example.com"
25
26
  },
26
27
  "auth": {
27
28
  "password": true,
@@ -48,7 +48,8 @@
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 },
52
+ "authFromAddress": { "type": "string", "maxLength": 254 }
52
53
  }
53
54
  },
54
55
  "auth": {
@@ -58,13 +58,14 @@ function providerConfigured(provider, selected, secrets) {
58
58
  }
59
59
 
60
60
  export async function confirmEmailSending(config, ui = clack) {
61
- const sender = config.email?.fromAddress ?? "";
62
- const domain = sender.includes("@") ? sender.slice(sender.lastIndexOf("@") + 1) : sender;
61
+ const reporterSender = config.email?.fromAddress ?? "";
62
+ const authSender = config.email?.authFromAddress ?? "";
63
+ const domains = [...new Set([reporterSender, authSender].filter(Boolean).map((sender) => sender.slice(sender.lastIndexOf("@") + 1)))];
63
64
  ui.note(
64
- `Sender: ${sender}\nDomain: ${domain}\n\n` +
65
+ `Reporter updates: ${reporterSender}\nAccount and invitation mail: ${authSender}\nDomain${domains.length === 1 ? "" : "s"}: ${domains.join(", ")}\n\n` +
65
66
  `1. In Cloudflare, open Compute → Email Service → Email Sending.\n` +
66
- `2. Onboard ${domain} and wait for its sending DNS records to show configured.\n` +
67
- `3. Return here. Domain onboarding is required to email arbitrary invited users.`,
67
+ `2. Onboard ${domains.join(" and ")} and wait for the sending DNS records to show configured.\n` +
68
+ `3. Return here. Resolve keeps participant mail separate from account and invitation mail.`,
68
69
  "Email delivery",
69
70
  );
70
71
  const action = await ui.select({
@@ -77,12 +77,18 @@ export function parseReportsConfig(value) {
77
77
  if (!EMAIL.test(ownerEmail)) throw new ReportsConfigError("owner.email is invalid");
78
78
  const retention = object(input.retention, "retention");
79
79
  exactKeys(retention, new Set(["reportsDays", "messagesDays", "appealWindowDays"]), "retention");
80
- const email = input.email === undefined ? { enabled: false, fromAddress: "" } : object(input.email, "email");
81
- exactKeys(email, new Set(["enabled", "fromAddress"]), "email");
80
+ const email = input.email === undefined ? { enabled: false, fromAddress: "", authFromAddress: "" } : object(input.email, "email");
81
+ exactKeys(email, new Set(["enabled", "fromAddress", "authFromAddress"]), "email");
82
82
  if (typeof email.enabled !== "boolean") throw new ReportsConfigError("email.enabled must be boolean");
83
83
  const emailFromAddress = String(email.fromAddress ?? "").trim().toLowerCase();
84
+ const emailDomain = emailFromAddress.includes("@") ? emailFromAddress.slice(emailFromAddress.lastIndexOf("@") + 1) : "";
85
+ const defaultAuthEmail = emailDomain ? `${emailFromAddress.startsWith("accounts@") ? "auth" : "accounts"}@${emailDomain}` : "";
86
+ const authEmailFromAddress = String(email.authFromAddress ?? defaultAuthEmail).trim().toLowerCase();
84
87
  if (email.enabled && !EMAIL.test(emailFromAddress)) throw new ReportsConfigError("email.fromAddress must be a valid onboarded sender when email is enabled");
88
+ if (email.enabled && !EMAIL.test(authEmailFromAddress)) throw new ReportsConfigError("email.authFromAddress must be a valid onboarded sender when email is enabled");
89
+ if (email.enabled && authEmailFromAddress === emailFromAddress) throw new ReportsConfigError("email.authFromAddress must differ from email.fromAddress");
85
90
  if (!email.enabled && emailFromAddress) throw new ReportsConfigError("email.fromAddress must be empty when email is disabled");
91
+ if (!email.enabled && email.authFromAddress) throw new ReportsConfigError("email.authFromAddress must be empty when email is disabled");
86
92
  const auth = input.auth === undefined ? { password: true, providers: [] } : object(input.auth, "auth");
87
93
  exactKeys(auth, new Set(["password", "providers"]), "auth");
88
94
  if (auth.password !== true) throw new ReportsConfigError("auth.password must be true because password recovery is a required sign-in method");
@@ -112,7 +118,7 @@ export function parseReportsConfig(value) {
112
118
  publicBaseUrl: origin(input.publicBaseUrl, "publicBaseUrl"),
113
119
  allowedOrigins: stringList(input.allowedOrigins, "allowedOrigins", 20, origin),
114
120
  owner: { email: ownerEmail },
115
- email: { enabled: email.enabled, fromAddress: emailFromAddress },
121
+ email: { enabled: email.enabled, fromAddress: emailFromAddress, authFromAddress: authEmailFromAddress },
116
122
  auth: { password: true, providers: authProviders },
117
123
  retention: {
118
124
  reportsDays: integer(retention.reportsDays, "retention.reportsDays", 30, 3_650),
@@ -146,7 +152,10 @@ export function buildReportsPlan(config) {
146
152
  queues: [config.resources.reportQueueName, config.resources.deliveryQueueName, config.resources.operationsDlqName],
147
153
  durableObject: "WorkspacePresence (SQLite-backed, declared by Worker migration)",
148
154
  workersAiBinding: "AI",
149
- emailServiceBinding: `EMAIL (${config.email.fromAddress || "not configured"})`,
155
+ emailServiceBindings: {
156
+ reporter: `REPORTER_EMAIL (${config.email.fromAddress || "not configured"})`,
157
+ authentication: `AUTH_EMAIL (${config.email.authFromAddress || "not configured"})`,
158
+ },
150
159
  staticAssets: "public/",
151
160
  publicHostname,
152
161
  customDomain: customDomain ?? "workers.dev hostname (no custom domain mutation)",
@@ -1,4 +1,5 @@
1
1
  import { canonicalJson } from "./audit";
2
+ import { reporterEmailConfigured } from "./report-email";
2
3
  import { decodeApiCursor, encodeApiCursor } from "./api-cursor";
3
4
  import {
4
5
  assertBackupTransferAllowed,
@@ -409,15 +410,17 @@ function publishWorkspaceEvent(
409
410
 
410
411
  function deliveryOptions(env: Env) {
411
412
  return {
413
+ emailEnabled: reporterEmailConfigured(env),
414
+ contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
415
+ recipientHashPepper: env.RATE_LIMIT_PEPPER,
412
416
  notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
413
417
  };
414
418
  }
415
419
 
416
420
  function intakeDeliveryOptions(env: Env) {
417
- const emailBinding = (env as unknown as { EMAIL?: unknown }).EMAIL;
418
421
  return {
419
422
  contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
420
- emailEnabled: Boolean(emailBinding && env.EMAIL_FROM_ADDRESS?.trim()),
423
+ emailEnabled: reporterEmailConfigured(env),
421
424
  notificationEnabled: Boolean(env.NOTIFICATION_WEBHOOK_URL?.trim() && env.NOTIFICATION_WEBHOOK_SECRET?.trim()),
422
425
  };
423
426
  }
@@ -2592,7 +2595,10 @@ async function handleAdminAiQualitySample(
2592
2595
  const sample = await loadAiQualitySample(env.DB, sampleId);
2593
2596
  if (!sample) throw new ApiError(404, "quality_sample_not_found", "The quality sample does not exist.");
2594
2597
  await assertOperatorReportAccess(env.DB, session, String(sample.reportId));
2595
- const report = await loadReportDetail(env.DB, String(sample.reportId));
2598
+ const report = await loadReportDetail(env.DB, String(sample.reportId), {
2599
+ contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
2600
+ includeReporterEmail: session.permissions.messageReporters,
2601
+ });
2596
2602
  if (!report) throw new ApiError(404, "report_not_found", "The sampled report does not exist.");
2597
2603
  return json({ schema_version: "1", sample: projectOperatorFields(session, "quality_sample", sample), report: projectOperatorFields(session, "report", report) });
2598
2604
  }
@@ -2840,7 +2846,10 @@ async function handleAdminReportDetail(request: Request, env: Env, reportId: str
2840
2846
  if (request.method !== "GET") return methodNotAllowed(["GET"]);
2841
2847
  const session = await requireOperator(request, env, "readReports");
2842
2848
  await assertOperatorReportAccess(env.DB, session, reportId);
2843
- const report = await loadReportDetail(env.DB, reportId);
2849
+ const report = await loadReportDetail(env.DB, reportId, {
2850
+ contactEncryptionKey: env.CREDENTIAL_ENCRYPTION_KEY,
2851
+ includeReporterEmail: session.permissions.messageReporters,
2852
+ });
2844
2853
  if (!report) throw new ApiError(404, "report_not_found", "The report does not exist.");
2845
2854
  return json({ schema_version: "1", report: projectOperatorFields(session, "report", report) });
2846
2855
  }
@@ -2879,7 +2888,13 @@ async function handleAdminReportMessage(
2879
2888
  );
2880
2889
  queueDeliveryJobs(env, ctx, result.jobs);
2881
2890
  publishWorkspaceEvent(env, ctx, { type: "reports_changed", reportId });
2882
- return json({ schema_version: "1", message_id: result.messageId, idempotent_replay: result.idempotentReplay }, {
2891
+ return json({
2892
+ schema_version: "1",
2893
+ message_id: result.messageId,
2894
+ delivery_state: result.deliveryState,
2895
+ delivery_channel: result.deliveryChannel,
2896
+ idempotent_replay: result.idempotentReplay,
2897
+ }, {
2883
2898
  status: result.idempotentReplay ? 200 : 201,
2884
2899
  });
2885
2900
  }
@@ -1,6 +1,7 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import { sha256 } from "./report-crypto";
3
3
  import { ApiError } from "./report-http";
4
+ import { reporterEmailConfigured } from "./report-email";
4
5
  import type { OperatorSession } from "./report-types";
5
6
  import { WORKFLOW_COMPILER_VERSION } from "./workflow-compiler";
6
7
  import { parseWorkspaceBrand } from "./workspace-branding";
@@ -280,7 +281,7 @@ export async function verifyFeature(env: Env, feature: string, actorId: string):
280
281
  else if (feature === "workers_ai") { ready = Boolean((env as unknown as { AI?: unknown }).AI); }
281
282
  else if (feature === "object_storage") { ready = Boolean(env.PROFILE_MEDIA && env.WORKFLOW_ARTIFACTS && env.EVIDENCE_OBJECTS && env.EXPORTS); capability = { buckets: ready ? 4 : 0 }; }
282
283
  else if (feature === "queues") { ready = Boolean(env.REPORT_JOBS && env.DELIVERY_JOBS && env.CONTROL_JOBS); capability = { producers: ready ? 3 : 0 }; }
283
- else { ready = Boolean((env as unknown as { EMAIL?: unknown }).EMAIL && env.EMAIL_FROM_ADDRESS); capability = { optional: true }; }
284
+ else { ready = reporterEmailConfigured(env); capability = { optional: true, channel: "reporter_email" }; }
284
285
  } catch { errorCode = "feature_probe_failed"; }
285
286
  const state = ready ? "ready" : feature === "email" ? "disabled" : "setup_required";
286
287
  const timestamp = now();
@@ -1,5 +1,6 @@
1
1
  import { canonicalJson } from "./audit";
2
2
  import { evaluateAiFailurePause } from "./report-ai-quality";
3
+ import { selectParticipantDelivery } from "./report-email";
3
4
  import type { AiJob, DeliveryJob, ReportState } from "./report-types";
4
5
  import type { AiMode, ValidatedAiResult } from "./report-ai-validation";
5
6
  import { validateAiResult } from "./report-ai-validation";
@@ -58,6 +59,7 @@ interface AiContextRow extends AiConfigRow {
58
59
  job_state: "pending" | "enqueued" | "processing" | "complete" | "failed" | "dead_letter";
59
60
  attempt_count: number;
60
61
  report_id: string;
62
+ installation_id: string;
61
63
  public_reference: string;
62
64
  source: string;
63
65
  report_state: ReportState;
@@ -67,6 +69,8 @@ interface AiContextRow extends AiConfigRow {
67
69
  target_reference: string;
68
70
  signed_facts_json: string;
69
71
  reporter_reference: string | null;
72
+ reporter_contact_mode: string;
73
+ reporter_contact_ciphertext: string | null;
70
74
  queue_ai_instructions: string | null;
71
75
  }
72
76
 
@@ -224,10 +228,12 @@ function randomSample(rate: number): boolean {
224
228
 
225
229
  async function aiContext(db: D1Database, jobId: string): Promise<AiContextRow | null> {
226
230
  return db.prepare(`
227
- SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id,
231
+ SELECT j.id AS job_id, j.state AS job_state, j.attempt_count, r.id AS report_id, r.installation_id,
228
232
  r.public_reference, r.source, r.state AS report_state, r.reason_code, r.form_version_id,
229
233
  t.target_type, t.target_reference, t.signed_facts_json,
230
- p.reference AS reporter_reference, qv.ai_instructions AS queue_ai_instructions, c.*
234
+ p.reference AS reporter_reference, p.contact_mode AS reporter_contact_mode,
235
+ p.contact_ciphertext AS reporter_contact_ciphertext,
236
+ qv.ai_instructions AS queue_ai_instructions, c.*
231
237
  FROM background_jobs j
232
238
  JOIN reports r ON r.id = j.report_id
233
239
  JOIN report_targets t ON t.report_id = r.id
@@ -431,6 +437,9 @@ async function providerFailure(db: D1Database, context: AiContextRow, runId: str
431
437
  }
432
438
 
433
439
  export interface AiProcessOptions {
440
+ emailEnabled: boolean;
441
+ contactEncryptionKey: string;
442
+ recipientHashPepper: string;
434
443
  notificationEnabled: boolean;
435
444
  }
436
445
 
@@ -643,6 +652,14 @@ export async function processAiJob(
643
652
  const messageId = crypto.randomUUID();
644
653
  const noticeId = crypto.randomUUID();
645
654
  const notificationId = crypto.randomUUID();
655
+ const participantDelivery = await selectParticipantDelivery({
656
+ reportId: context.report_id,
657
+ audience: "reporter",
658
+ contactMode: context.reporter_contact_mode,
659
+ contactCiphertext: context.reporter_contact_ciphertext,
660
+ reference: context.reporter_reference,
661
+ ...options,
662
+ });
646
663
  let templateId: string;
647
664
  let variables: Record<string, string>;
648
665
  let nextState: ReportState;
@@ -721,7 +738,7 @@ export async function processAiJob(
721
738
  ) VALUES (?1, ?2, 'reporter', 'outbound', 'ai', ?3, ?4, ?5, 1, ?6, ?7, ?8)
722
739
  `).bind(
723
740
  messageId, context.report_id, context.id, renderedBody, templateId,
724
- options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-message:${storedRun.id}`,
741
+ participantDelivery.state, finishedAt, `ai-message:${storedRun.id}`,
725
742
  ),
726
743
  usageStatement(db, context.id, nextState === "resolved_by_ai" ? { resolved: 1 } : { waiting: 1 }, finishedAt),
727
744
  auditEvent(db, {
@@ -760,10 +777,18 @@ export async function processAiJob(
760
777
  ) VALUES (?1, ?2, ?3, 'reporter', ?4, ?5, ?6, ?7, ?7, ?8, ?9)
761
778
  `).bind(
762
779
  noticeId, context.report_id, nextState === "resolved_by_ai" ? decisionId : null, templateId, renderedBody,
763
- options.notificationEnabled ? "queued" : "suppressed", finishedAt, `ai-notice:${storedRun.id}`, messageId,
780
+ participantDelivery.state, finishedAt, `ai-notice:${storedRun.id}`, messageId,
764
781
  ));
765
782
  const jobs: DeliveryJob[] = [];
766
- if (options.notificationEnabled) {
783
+ if (participantDelivery.channel === "email" && participantDelivery.recipientHash) {
784
+ statements.push(db.prepare(`
785
+ INSERT INTO email_deliveries (
786
+ id, installation_id, message_id, audience, recipient_hash,
787
+ provider, state, attempt_count, next_attempt_at, created_at, updated_at
788
+ ) VALUES (?1, ?2, ?3, 'reporter', ?4, 'cloudflare_email_service', 'pending', 0, ?5, ?5, ?5)
789
+ `).bind(notificationId, context.installation_id, messageId, participantDelivery.recipientHash, finishedAt));
790
+ jobs.push({ version: 1, jobId: `email:${notificationId}`, type: "deliver_email", outboxId: notificationId });
791
+ } else if (participantDelivery.channel === "customer_webhook") {
767
792
  statements.push(db.prepare(`
768
793
  INSERT INTO notification_outbox (
769
794
  id, notice_id, idempotency_key, channel, payload_json, status, next_attempt_at, created_at, updated_at
@@ -2,6 +2,7 @@ import { betterAuth } from "better-auth";
2
2
  import { getMigrations } from "better-auth/db/migration";
3
3
  import { genericOAuth } from "better-auth/plugins";
4
4
  import type { GenericOAuthConfig } from "better-auth/plugins/generic-oauth";
5
+ import { authenticationEmailBinding } from "./report-email";
5
6
 
6
7
  const INVITATION_COOKIE = "safest_invitation";
7
8
  const INVITATION_MAX_AGE_SECONDS = 7 * 24 * 60 * 60;
@@ -9,16 +10,6 @@ const OWNER_SETUP_COOKIE = "safest_owner_setup";
9
10
  const OWNER_SETUP_RATE_LIMIT = 12;
10
11
  const OWNER_SETUP_WINDOW_SECONDS = 10 * 60;
11
12
 
12
- type AuthEmailBinding = {
13
- send(message: {
14
- from: { email: string; name?: string };
15
- to: string | string[];
16
- subject: string;
17
- text: string;
18
- html: string;
19
- }): Promise<unknown>;
20
- };
21
-
22
13
  type BetterAuthEnv = Env & {
23
14
  BETTER_AUTH_SECRET?: string;
24
15
  AUTH_EMAIL_FROM?: string;
@@ -28,7 +19,6 @@ type BetterAuthEnv = Env & {
28
19
  AUTH_GITHUB_CLIENT_SECRET?: string;
29
20
  AUTH_CLOUDFLARE_CLIENT_ID?: string;
30
21
  AUTH_CLOUDFLARE_CLIENT_SECRET?: string;
31
- EMAIL?: AuthEmailBinding;
32
22
  };
33
23
 
34
24
  type BetterAuthSession = {
@@ -274,11 +264,12 @@ async function sendAuthEmail(
274
264
  env: BetterAuthEnv,
275
265
  message: { to: string; subject: string; heading: string; body: string; action: string; url: string },
276
266
  ): Promise<void> {
277
- if (localDevelopment(env) && (!env.EMAIL || !env.AUTH_EMAIL_FROM)) {
267
+ const email = authenticationEmailBinding(env);
268
+ if (localDevelopment(env) && (!email || !env.AUTH_EMAIL_FROM)) {
278
269
  console.info(`Local authentication email (${message.subject}) for ${message.to}: ${message.url}`);
279
270
  return;
280
271
  }
281
- if (!env.EMAIL || !env.AUTH_EMAIL_FROM) {
272
+ if (!email || !env.AUTH_EMAIL_FROM) {
282
273
  throw new Error("Cloudflare Email Service and AUTH_EMAIL_FROM are required for authentication email.");
283
274
  }
284
275
  const footer = "If you did not request this message, you can safely ignore it.";
@@ -288,7 +279,7 @@ async function sendAuthEmail(
288
279
  `<p><a href="${escapeHtml(message.url)}" style="display:inline-block;padding:10px 16px;` +
289
280
  `background:#171717;color:#fff;text-decoration:none;border-radius:6px">${escapeHtml(message.action)}</a></p>` +
290
281
  `<p style="font-size:12px;color:#666">${escapeHtml(footer)}</p></body></html>`;
291
- await env.EMAIL.send({
282
+ await email.send({
292
283
  from: { name: "Safest Resolve", email: env.AUTH_EMAIL_FROM },
293
284
  to: message.to,
294
285
  subject: message.subject,
@@ -2,6 +2,7 @@ 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
6
 
6
7
  const MAX_ATTEMPTS = 8;
7
8
  const RESPONSE_LIMIT = 16 * 1024;
@@ -449,8 +450,8 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
449
450
  const email = row.contactCiphertext
450
451
  ? (await decryptReportContact(env.CREDENTIAL_ENCRYPTION_KEY, row.contactCiphertext, row.reportId, row.audience)).trim().toLowerCase()
451
452
  : row.recipientReference?.trim().toLowerCase() ?? "";
452
- const from = env.EMAIL_FROM_ADDRESS?.trim().toLowerCase() ?? "";
453
- const binding = (env as unknown as { EMAIL?: { send(message: Record<string, unknown>): Promise<{ messageId?: string }> } }).EMAIL;
453
+ const from = reporterEmailFrom(env);
454
+ const binding = reporterEmailBinding(env);
454
455
  let reservations;
455
456
  try {
456
457
  reservations = await reserveBudgets(db, {
@@ -484,7 +485,7 @@ async function processEmail(db: D1Database, deliveryId: string, env: Env): Promi
484
485
  if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email) || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(from)) {
485
486
  throw new DeliveryConfigurationError("Email sender or recipient configuration is invalid.");
486
487
  }
487
- const prefix = env.EMAIL_SUBJECT_PREFIX?.trim().slice(0, 120) || "Safest report update";
488
+ const prefix = reporterEmailSubjectPrefix(env);
488
489
  const result = await binding.send({
489
490
  to: email,
490
491
  from,
@@ -0,0 +1,106 @@
1
+ export type AuthenticationEmailBinding = {
2
+ send(message: {
3
+ from: { email: string; name?: string };
4
+ to: string | string[];
5
+ subject: string;
6
+ text: string;
7
+ html: string;
8
+ }): Promise<unknown>;
9
+ };
10
+
11
+ export type ReporterEmailBinding = {
12
+ send(message: {
13
+ from: string;
14
+ to: string;
15
+ subject: string;
16
+ text: string;
17
+ }): Promise<{ messageId?: string }>;
18
+ };
19
+
20
+ type EmailEnvironment = {
21
+ AUTH_EMAIL?: AuthenticationEmailBinding;
22
+ REPORTER_EMAIL?: ReporterEmailBinding;
23
+ /** Compatibility with installations created before the email channels were separated. */
24
+ EMAIL?: AuthenticationEmailBinding & ReporterEmailBinding;
25
+ AUTH_EMAIL_FROM?: string;
26
+ REPORTER_EMAIL_FROM?: string;
27
+ REPORTER_EMAIL_SUBJECT_PREFIX?: string;
28
+ /** Compatibility with installations created before the email channels were separated. */
29
+ EMAIL_FROM_ADDRESS?: string;
30
+ EMAIL_SUBJECT_PREFIX?: string;
31
+ };
32
+
33
+ function emailEnvironment(env: unknown): EmailEnvironment {
34
+ return env as EmailEnvironment;
35
+ }
36
+
37
+ export function authenticationEmailBinding(env: unknown): AuthenticationEmailBinding | undefined {
38
+ const bindings = emailEnvironment(env);
39
+ return bindings.AUTH_EMAIL ?? bindings.EMAIL;
40
+ }
41
+
42
+ export function reporterEmailBinding(env: unknown): ReporterEmailBinding | undefined {
43
+ const bindings = emailEnvironment(env);
44
+ return bindings.REPORTER_EMAIL ?? bindings.EMAIL;
45
+ }
46
+
47
+ export function reporterEmailFrom(env: unknown): string {
48
+ const bindings = emailEnvironment(env);
49
+ return (bindings.REPORTER_EMAIL_FROM ?? bindings.EMAIL_FROM_ADDRESS ?? "").trim().toLowerCase();
50
+ }
51
+
52
+ export function reporterEmailSubjectPrefix(env: unknown): string {
53
+ const bindings = emailEnvironment(env);
54
+ return (bindings.REPORTER_EMAIL_SUBJECT_PREFIX ?? bindings.EMAIL_SUBJECT_PREFIX ?? "").trim().slice(0, 120)
55
+ || "Safest report update";
56
+ }
57
+
58
+ export function reporterEmailConfigured(env: unknown): boolean {
59
+ return Boolean(reporterEmailBinding(env) && reporterEmailFrom(env));
60
+ }
61
+
62
+ export type ParticipantDelivery = {
63
+ channel: "email" | "customer_webhook" | null;
64
+ state: "queued" | "suppressed";
65
+ recipientHash?: string;
66
+ };
67
+
68
+ export async function selectParticipantDelivery(input: {
69
+ reportId: string;
70
+ audience: ParticipantAudience;
71
+ contactMode: string;
72
+ contactCiphertext: string | null;
73
+ reference: string | null;
74
+ emailEnabled: boolean;
75
+ notificationEnabled: boolean;
76
+ contactEncryptionKey: string;
77
+ recipientHashPepper: string;
78
+ }): Promise<ParticipantDelivery> {
79
+ if (input.emailEnabled && input.contactMode === "customer_notification" && input.contactCiphertext) {
80
+ try {
81
+ const email = (await decryptReportContact(
82
+ input.contactEncryptionKey,
83
+ input.contactCiphertext,
84
+ input.reportId,
85
+ input.audience,
86
+ )).trim().toLowerCase();
87
+ if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/u.test(email)) {
88
+ return {
89
+ channel: "email",
90
+ state: "queued",
91
+ recipientHash: `sha256:${await sha256(`email-recipient-v1:${input.recipientHashPepper}:${email}`)}`,
92
+ };
93
+ }
94
+ } catch {
95
+ // A damaged encrypted contact can still fall back to a customer-owned
96
+ // notification webhook when the report has an eligible participant.
97
+ }
98
+ }
99
+ if (input.notificationEnabled && input.contactMode === "customer_notification"
100
+ && (input.reference || input.contactCiphertext)) {
101
+ return { channel: "customer_webhook", state: "queued" };
102
+ }
103
+ return { channel: null, state: "suppressed" };
104
+ }
105
+ import { decryptReportContact, sha256 } from "./report-crypto";
106
+ import type { ParticipantAudience } from "./report-types";